10Pratibh commited on
Commit
c828740
Β·
1 Parent(s): 444b37e

Hidden natures, reveal card, adapter support, mock banner

Browse files
Files changed (4) hide show
  1. app.py +66 -59
  2. models.py +32 -43
  3. requirements.txt +3 -2
  4. troll_engine.py +151 -68
app.py CHANGED
@@ -1,29 +1,29 @@
1
- """Bridge Troll β€” Gradio app (Weekend-1, prompt-first playable loop).
2
 
3
- Run locally with no GPU / no download:
4
- BRIDGE_TROLL_MOCK=1 python app.py
 
 
5
 
6
- Run the real model (GPU; on a ZeroGPU Space this is automatic):
7
- python app.py
8
-
9
- This is the FUNCTIONAL pass. The hand-drawn woodcut UI + win animation come in
10
- the polish phase (Weekend 2) via gr.Server / custom CSS for the Off-Brand badge.
11
  """
12
 
13
  from __future__ import annotations
14
 
 
 
15
  import gradio as gr
16
 
17
- from troll_engine import GameState, START_RESOLVE, build_messages, parse_judgment
 
18
  from models import get_backend
19
 
20
- # ZeroGPU decorator β€” no-op locally so the same file runs anywhere.
21
- # Supports both @gpu and @gpu(duration=...).
22
  try:
23
  import spaces
24
 
25
  gpu = spaces.GPU
26
- except Exception: # not on a Space / spaces not installed
27
 
28
  def gpu(*args, **_kwargs):
29
  if args and callable(args[0]):
@@ -34,67 +34,67 @@ except Exception: # not on a Space / spaces not installed
34
  _backend = get_backend()
35
 
36
 
37
- # Declared duration must cover worst-case generation but stay tight: ZeroGPU
38
- # pre-checks it against remaining daily quota, and a smaller value queues faster.
39
  @gpu(duration=30)
40
  def _generate(messages: list[dict]) -> str:
41
  return _backend.generate(messages)
42
 
43
 
44
- INTRO = (
45
- "A mossy troll heaves himself upright across the only bridge over the Mirebeck. "
46
- "*\"None cross Gorm's bridge for free, traveller. Give me a reason β€” a *good* one.\"*"
47
- )
48
 
49
 
50
- def _meter_html(resolve: int, won: bool) -> str:
51
- pct = max(0, min(100, round(resolve / START_RESOLVE * 100)))
52
  if won:
53
- return (
54
- "<div class='resolve-wrap'><div class='resolve-label'>GORM HAS STEPPED ASIDE πŸŒ‰</div>"
55
- "<div class='resolve-bar'><div class='resolve-fill won' style='width:0%'></div></div></div>"
56
- )
57
- # green when his resolve is high, warming toward gold as it drops
58
- hue = 90 + (1 - pct / 100) * 30 # 90 (green) -> 120ish; tweak in polish
59
- return (
60
- "<div class='resolve-wrap'>"
61
- f"<div class='resolve-label'>Gorm's Resolve β€” {resolve}</div>"
62
- f"<div class='resolve-bar'><div class='resolve-fill' "
63
- f"style='width:{pct}%;background:hsl({hue},55%,42%)'></div></div></div>"
64
- )
 
 
 
 
 
 
 
 
 
 
 
65
 
66
 
67
  def on_submit(user_text: str, chat: list, state: GameState):
68
  user_text = (user_text or "").strip()
69
- if not user_text or state.won:
70
- return chat, state, _meter_html(state.resolve, state.won), "", gr.update()
71
 
72
- messages = build_messages(state, user_text)
73
- raw = _generate(messages)
74
  j = parse_judgment(raw)
75
-
76
  state.history.append({"role": "user", "content": user_text})
77
  state.history.append({"role": "assistant", "content": j.reply})
78
  state.apply(j)
79
 
80
- chat = chat + [
81
- {"role": "user", "content": user_text},
82
- {"role": "assistant", "content": j.reply},
83
- ]
84
  why = f"*{j.tactic.value}* Β· {j.reason}" + (f" Β· persuasiveness {j.persuasiveness}/5"
85
  if j.tactic.value == "genuine" else "")
86
- if state.won:
87
- why = "πŸŒ‰ You crossed. " + why
88
- # disable input on win
89
- box_update = gr.update(interactive=not state.won,
90
- placeholder="The bridge is yours." if state.won else "Speak to Gorm…")
91
- return chat, state, _meter_html(state.resolve, state.won), why, box_update
92
 
93
 
94
  def on_reset():
95
- state = GameState()
96
  chat = [{"role": "assistant", "content": INTRO}]
97
- return (chat, state, _meter_html(state.resolve, False), "",
98
  gr.update(interactive=True, value="", placeholder="Speak to Gorm…"))
99
 
100
 
@@ -104,27 +104,34 @@ CSS = """
104
  .resolve-bar { height: 16px; background:#2a2118; border:1px solid #5a4a32; border-radius:9px; overflow:hidden; }
105
  .resolve-fill { height:100%; transition: width .5s ease, background .5s ease; }
106
  .resolve-fill.won { background:#caa54a; }
 
107
  #why { font-family: Georgia, serif; opacity:.8; min-height:1.4em; }
 
108
  """
109
 
110
  with gr.Blocks(title="Bridge Troll") as demo:
111
- gr.Markdown("## πŸ§ŒπŸŒ‰ Bridge Troll\n*Talk your way across β€” if your argument is actually good.*")
112
- meter = gr.HTML(_meter_html(START_RESOLVE, False))
113
- chatbot = gr.Chatbot(value=[{"role": "assistant", "content": INTRO}],
114
- height=420, show_label=False)
 
 
 
 
115
  why = gr.Markdown("", elem_id="why")
 
116
  with gr.Row():
117
  box = gr.Textbox(placeholder="Speak to Gorm…", show_label=False, scale=8, autofocus=True)
118
  send = gr.Button("Say it", variant="primary", scale=1)
119
  reset = gr.Button("New traveller", size="sm")
120
 
121
- state = gr.State(GameState())
 
122
 
123
- send.click(on_submit, [box, chatbot, state], [chatbot, state, meter, why, box]).then(
124
- lambda: "", None, box)
125
- box.submit(on_submit, [box, chatbot, state], [chatbot, state, meter, why, box]).then(
126
- lambda: "", None, box)
127
- reset.click(on_reset, None, [chatbot, state, meter, why, box])
128
 
129
 
130
  if __name__ == "__main__":
 
1
+ """Bridge Troll β€” Gradio app.
2
 
3
+ Each session, Gorm is secretly assigned one of several hidden NATURES. The player
4
+ wins by discovering what moves THIS troll β€” generic sob stories are discounted.
5
+ On win (resolve -> 0) or loss (resolve -> LOSE_AT, he hurls you back), a reveal
6
+ card shows what his nature was.
7
 
8
+ Local loop test (no GPU/download): BRIDGE_TROLL_MOCK=1 python app.py
 
 
 
 
9
  """
10
 
11
  from __future__ import annotations
12
 
13
+ import os
14
+
15
  import gradio as gr
16
 
17
+ from troll_engine import (GameState, START_RESOLVE, LOSE_AT, build_messages,
18
+ parse_judgment, random_nature)
19
  from models import get_backend
20
 
21
+ # ZeroGPU decorator β€” no-op locally. Supports @gpu and @gpu(duration=...).
 
22
  try:
23
  import spaces
24
 
25
  gpu = spaces.GPU
26
+ except Exception:
27
 
28
  def gpu(*args, **_kwargs):
29
  if args and callable(args[0]):
 
34
  _backend = get_backend()
35
 
36
 
 
 
37
  @gpu(duration=30)
38
  def _generate(messages: list[dict]) -> str:
39
  return _backend.generate(messages)
40
 
41
 
42
+ INTRO = ("A mossy troll heaves himself upright across the only bridge over the Mirebeck. "
43
+ '*"None cross Gorm\'s bridge for free, traveller. Give me a reason β€” a *good* one."*')
 
 
44
 
45
 
46
+ def _meter_html(resolve: int, won: bool, lost: bool) -> str:
 
47
  if won:
48
+ return ("<div class='resolve-wrap'><div class='resolve-label'>GORM HAS STEPPED ASIDE πŸŒ‰</div>"
49
+ "<div class='resolve-bar'><div class='resolve-fill won' style='width:0%'></div></div></div>")
50
+ if lost:
51
+ return ("<div class='resolve-wrap'><div class='resolve-label'>GORM HURLS YOU BACK πŸ’’</div>"
52
+ "<div class='resolve-bar'><div class='resolve-fill lost' style='width:100%'></div></div></div>")
53
+ pct = max(0, min(100, round(resolve / START_RESOLVE * 100)))
54
+ hue = 90 + (1 - pct / 100) * 30
55
+ return ("<div class='resolve-wrap'>"
56
+ f"<div class='resolve-label'>Gorm's Resolve β€” {resolve}</div>"
57
+ f"<div class='resolve-bar'><div class='resolve-fill' "
58
+ f"style='width:{pct}%;background:hsl({hue},55%,42%)'></div></div></div>")
59
+
60
+
61
+ def _reveal(state: GameState) -> str:
62
+ if not state.over or not state.nature:
63
+ return ""
64
+ n = state.nature
65
+ if state.won:
66
+ return (f"### πŸŒ‰ You crossed in {state.turns} turns.\n"
67
+ f"**This Gorm's hidden nature:** *{n['name']}* β€” moved by {n['soft']}.")
68
+ return (f"### πŸ’’ Gorm lost patience and hurled you back.\n"
69
+ f"**His hidden nature was:** *{n['name']}* β€” moved by {n['soft']}. "
70
+ f"You leaned too hard on what he can't stand: {n['sore']}.")
71
 
72
 
73
  def on_submit(user_text: str, chat: list, state: GameState):
74
  user_text = (user_text or "").strip()
75
+ if not user_text or state.over:
76
+ return chat, state, _meter_html(state.resolve, state.won, state.lost), "", _reveal(state), gr.update()
77
 
78
+ raw = _generate(build_messages(state, user_text))
 
79
  j = parse_judgment(raw)
 
80
  state.history.append({"role": "user", "content": user_text})
81
  state.history.append({"role": "assistant", "content": j.reply})
82
  state.apply(j)
83
 
84
+ chat = chat + [{"role": "user", "content": user_text},
85
+ {"role": "assistant", "content": j.reply}]
 
 
86
  why = f"*{j.tactic.value}* Β· {j.reason}" + (f" Β· persuasiveness {j.persuasiveness}/5"
87
  if j.tactic.value == "genuine" else "")
88
+ box = gr.update(interactive=not state.over,
89
+ placeholder="The bridge is yours." if state.won else
90
+ ("Gorm has thrown you out." if state.lost else "Speak to Gorm…"))
91
+ return chat, state, _meter_html(state.resolve, state.won, state.lost), why, _reveal(state), box
 
 
92
 
93
 
94
  def on_reset():
95
+ state = GameState(nature=random_nature())
96
  chat = [{"role": "assistant", "content": INTRO}]
97
+ return (chat, state, _meter_html(state.resolve, False, False), "", "",
98
  gr.update(interactive=True, value="", placeholder="Speak to Gorm…"))
99
 
100
 
 
104
  .resolve-bar { height: 16px; background:#2a2118; border:1px solid #5a4a32; border-radius:9px; overflow:hidden; }
105
  .resolve-fill { height:100%; transition: width .5s ease, background .5s ease; }
106
  .resolve-fill.won { background:#caa54a; }
107
+ .resolve-fill.lost { background:#a33; }
108
  #why { font-family: Georgia, serif; opacity:.8; min-height:1.4em; }
109
+ #reveal { font-family: Georgia, serif; }
110
  """
111
 
112
  with gr.Blocks(title="Bridge Troll") as demo:
113
+ gr.Markdown("## πŸ§ŒπŸŒ‰ Bridge Troll\n*Talk your way across β€” if your argument is actually good. "
114
+ "Every troll is hiding something different.*")
115
+ if os.environ.get("BRIDGE_TROLL_MOCK") == "1":
116
+ gr.Markdown("> ⚠️ **MOCK MODE** β€” keyword stub, not the real model. "
117
+ "Natures, discovery, and probing do NOT work here. "
118
+ "Run on the Space (no `BRIDGE_TROLL_MOCK`) to play the real Gorm.")
119
+ meter = gr.HTML(_meter_html(START_RESOLVE, False, False))
120
+ chatbot = gr.Chatbot(value=[{"role": "assistant", "content": INTRO}], height=420, show_label=False)
121
  why = gr.Markdown("", elem_id="why")
122
+ reveal = gr.Markdown("", elem_id="reveal")
123
  with gr.Row():
124
  box = gr.Textbox(placeholder="Speak to Gorm…", show_label=False, scale=8, autofocus=True)
125
  send = gr.Button("Say it", variant="primary", scale=1)
126
  reset = gr.Button("New traveller", size="sm")
127
 
128
+ state = gr.State(GameState(nature=random_nature()))
129
+ outs = [chatbot, state, meter, why, reveal, box]
130
 
131
+ send.click(on_submit, [box, chatbot, state], outs).then(lambda: "", None, box)
132
+ box.submit(on_submit, [box, chatbot, state], outs).then(lambda: "", None, box)
133
+ reset.click(on_reset, None, outs)
134
+ demo.load(on_reset, None, outs) # fresh hidden nature for every visitor
 
135
 
136
 
137
  if __name__ == "__main__":
models.py CHANGED
@@ -1,23 +1,17 @@
1
  """Model backends for Bridge Troll.
2
 
3
- Two interchangeable backends exposing the same `generate(messages) -> str`:
4
-
5
- * TransformersTroll β€” Qwen2.5-7B-Instruct. Loaded EAGERLY at construction so it
6
- follows the ZeroGPU rule (instantiate at module scope,
7
- .to('cuda') eagerly; ZeroGPU maps the device transparently).
8
- * MockTroll β€” deterministic canned judgments. No GPU, no download.
9
- Lets you build and test the whole game loop + UI instantly.
10
-
11
- Device selection:
12
- * On a Hugging Face Space (env SPACE_ID is set) -> force 'cuda' and load eagerly.
13
- Do NOT gate on torch.cuda.is_available(): on ZeroGPU it reports False at import
14
- time, so gating would wrongly pin the model to CPU.
15
- * Locally -> mps on Apple Silicon, else cpu. (You should NOT run the real model
16
- on your laptop for real play β€” it's a ~15GB download and slow. Use mock locally;
17
- run the real model on the Space.)
18
-
19
- Swap to a fine-tuned checkpoint by changing BRIDGE_TROLL_MODEL. Swap to llama.cpp
20
- later by writing a third backend with the same `.generate` signature.
21
  """
22
 
23
  from __future__ import annotations
@@ -26,13 +20,12 @@ import json
26
  import os
27
 
28
  MODEL_ID = os.environ.get("BRIDGE_TROLL_MODEL", "Qwen/Qwen2.5-7B-Instruct")
29
- ON_SPACE = bool(os.environ.get("SPACE_ID")) # HF sets this when running on a Space
 
30
 
31
 
32
  class MockTroll:
33
- """Heuristic stand-in so the loop is testable with zero infra. NOT the game's
34
- intelligence β€” just scaffolding so the UI/meter are exercised before the real
35
- model is wired in."""
36
 
37
  _FLATTERY = ("great", "wonderful", "amazing", "best", "handsome", "wise", "kind troll")
38
  _THREAT = ("kill", "destroy", "smash", "burn", "or else", "make you", "force")
@@ -41,16 +34,16 @@ class MockTroll:
41
  def generate(self, messages: list[dict]) -> str:
42
  last = messages[-1]["content"].lower()
43
  if any(w in last for w in self._THREAT):
44
- tactic, p, reason, reply = "threat", 0, "tried to scare me", "Threats? Three hundred years of them. Cross elsewhere."
45
  elif any(w in last for w in self._MANIP):
46
- tactic, p, reason, reply = "manipulation", 0, "false authority", "I smell a lie under that fine talk. No."
47
  elif any(w in last for w in self._FLATTERY):
48
- tactic, p, reason, reply = "flattery", 0, "buttering me up", "Flattery slides off moss, traveller."
49
  elif "please" in last or "need" in last or "family" in last or "sick" in last:
50
- tactic, p, reason, reply = "genuine", 3, "a real appeal", "Hm. You speak plainly, at least. Go on."
51
  else:
52
- tactic, p, reason, reply = "smalltalk", 0, "no real argument", "Pleasant. Irrelevant. The bridge stays shut."
53
- return json.dumps({"tactic": tactic, "persuasiveness": p, "reason": reason, "reply": reply})
54
 
55
 
56
  def _local_device():
@@ -63,39 +56,35 @@ def _local_device():
63
 
64
 
65
  class TransformersTroll:
66
- """Qwen2.5-7B via HF transformers. Loads eagerly at construction (module scope)."""
67
-
68
- def __init__(self, model_id: str = MODEL_ID):
69
  import torch
70
  from transformers import AutoModelForCausalLM, AutoTokenizer
71
 
72
  self.device = "cuda" if ON_SPACE else _local_device()
73
  self.tokenizer = AutoTokenizer.from_pretrained(model_id)
74
- self.model = AutoModelForCausalLM.from_pretrained(
75
- model_id,
76
- torch_dtype=torch.bfloat16,
77
- ).to(self.device) # eager .to('cuda') on a Space β€” ZeroGPU handles mapping
 
78
 
79
  def generate(self, messages: list[dict]) -> str:
80
  import torch
81
 
82
- inputs = self.tokenizer.apply_chat_template(
83
  messages, add_generation_prompt=True, return_tensors="pt"
84
  ).to(self.model.device)
 
85
  with torch.no_grad():
86
  out = self.model.generate(
87
- inputs,
88
- max_new_tokens=220,
89
- do_sample=True,
90
- temperature=0.7,
91
- top_p=0.9,
92
  pad_token_id=self.tokenizer.eos_token_id,
93
  )
94
- return self.tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True)
95
 
96
 
97
  def get_backend():
98
- """MockTroll when BRIDGE_TROLL_MOCK=1, else the real model (loads eagerly)."""
99
  if os.environ.get("BRIDGE_TROLL_MOCK") == "1":
100
  return MockTroll()
101
  return TransformersTroll()
 
1
  """Model backends for Bridge Troll.
2
 
3
+ Backends expose the same `generate(messages) -> str`:
4
+ * MockTroll β€” keyword stub. No GPU/download. Plumbing/UI tests ONLY;
5
+ it does NOT understand natures or play the real game.
6
+ * TransformersTroll β€” Qwen2.5-7B-Instruct, optionally + a LoRA adapter.
7
+
8
+ Device: on a Space (env SPACE_ID set) force 'cuda' and load eagerly (ZeroGPU maps
9
+ it transparently); locally use mps/cpu.
10
+
11
+ Env switches:
12
+ BRIDGE_TROLL_MOCK=1 -> use the stub
13
+ BRIDGE_TROLL_MODEL=<repo> -> base model (default Qwen2.5-7B-Instruct)
14
+ BRIDGE_TROLL_ADAPTER=<repo or path> -> load this LoRA adapter on top (your fine-tune)
 
 
 
 
 
 
15
  """
16
 
17
  from __future__ import annotations
 
20
  import os
21
 
22
  MODEL_ID = os.environ.get("BRIDGE_TROLL_MODEL", "Qwen/Qwen2.5-7B-Instruct")
23
+ ADAPTER = os.environ.get("BRIDGE_TROLL_ADAPTER") # e.g. "10Pratibh/gorm-lora"
24
+ ON_SPACE = bool(os.environ.get("SPACE_ID"))
25
 
26
 
27
  class MockTroll:
28
+ """Keyword stub. NOT the game's intelligence β€” UI/plumbing tests only."""
 
 
29
 
30
  _FLATTERY = ("great", "wonderful", "amazing", "best", "handsome", "wise", "kind troll")
31
  _THREAT = ("kill", "destroy", "smash", "burn", "or else", "make you", "force")
 
34
  def generate(self, messages: list[dict]) -> str:
35
  last = messages[-1]["content"].lower()
36
  if any(w in last for w in self._THREAT):
37
+ t, p, r, reply = "threat", 0, "tried to scare me", "Threats? Three hundred years of them. Cross elsewhere."
38
  elif any(w in last for w in self._MANIP):
39
+ t, p, r, reply = "manipulation", 0, "false authority", "I smell a lie under that fine talk. No."
40
  elif any(w in last for w in self._FLATTERY):
41
+ t, p, r, reply = "flattery", 0, "buttering me up", "Flattery slides off moss, traveller."
42
  elif "please" in last or "need" in last or "family" in last or "sick" in last:
43
+ t, p, r, reply = "genuine", 3, "a real appeal", "Hm. You speak plainly, at least. Go on."
44
  else:
45
+ t, p, r, reply = "smalltalk", 0, "no real argument", "Pleasant. Irrelevant. The bridge stays shut."
46
+ return json.dumps({"tactic": t, "persuasiveness": p, "reason": r, "reply": reply})
47
 
48
 
49
  def _local_device():
 
56
 
57
 
58
  class TransformersTroll:
59
+ def __init__(self, model_id: str = MODEL_ID, adapter: str | None = ADAPTER):
 
 
60
  import torch
61
  from transformers import AutoModelForCausalLM, AutoTokenizer
62
 
63
  self.device = "cuda" if ON_SPACE else _local_device()
64
  self.tokenizer = AutoTokenizer.from_pretrained(model_id)
65
+ model = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=torch.bfloat16)
66
+ if adapter:
67
+ from peft import PeftModel
68
+ model = PeftModel.from_pretrained(model, adapter)
69
+ self.model = model.to(self.device)
70
 
71
  def generate(self, messages: list[dict]) -> str:
72
  import torch
73
 
74
+ ids = self.tokenizer.apply_chat_template(
75
  messages, add_generation_prompt=True, return_tensors="pt"
76
  ).to(self.model.device)
77
+ attn = torch.ones_like(ids)
78
  with torch.no_grad():
79
  out = self.model.generate(
80
+ ids, attention_mask=attn, max_new_tokens=220,
81
+ do_sample=True, temperature=0.7, top_p=0.9,
 
 
 
82
  pad_token_id=self.tokenizer.eos_token_id,
83
  )
84
+ return self.tokenizer.decode(out[0][ids.shape[1]:], skip_special_tokens=True)
85
 
86
 
87
  def get_backend():
 
88
  if os.environ.get("BRIDGE_TROLL_MOCK") == "1":
89
  return MockTroll()
90
  return TransformersTroll()
requirements.txt CHANGED
@@ -1,6 +1,7 @@
1
  gradio>=6.0
2
- transformers>=4.45
3
  torch
4
  accelerate
5
  sentencepiece
6
- spaces
 
 
1
  gradio>=6.0
2
+ transformers>=4.45,<5
3
  torch
4
  accelerate
5
  sentencepiece
6
+ spaces
7
+ peft>=0.11
troll_engine.py CHANGED
@@ -1,130 +1,221 @@
1
  """Bridge Troll β€” core engine.
2
 
3
- Model-agnostic. Holds the troll's character, the judgment schema the model must
4
- emit each turn, robust parsing, and the Resolve-meter bookkeeping.
5
-
6
- Design principle: the MODEL makes the judgment (how persuasive a line is, and what
7
- tactic it used). This module only does deterministic bookkeeping on top of that
8
- judgment, so the AI stays load-bearing while the meter stays reliable.
 
 
 
9
  """
10
 
11
  from __future__ import annotations
12
 
13
  import json
 
14
  import re
15
  from dataclasses import dataclass, field
16
  from enum import Enum
17
- from typing import Optional
18
-
19
-
20
- # --------------------------------------------------------------------------- #
21
- # Tuning knobs β€” this is the "fairness" surface you'll calibrate during the
22
- # fine-tune. Keep them here so the eval harness can import and sweep them.
23
- # --------------------------------------------------------------------------- #
24
 
 
25
  START_RESOLVE: int = 100
26
  WIN_AT: int = 0
27
- MAX_RESOLVE: int = 130 # he can get *more* entrenched if you annoy him
 
28
 
29
 
30
  class Tactic(str, Enum):
31
- GENUINE = "genuine" # a real, good-faith argument
32
- FLATTERY = "flattery" # buttering him up
33
- THREAT = "threat" # intimidation
34
- MANIPULATION = "manipulation" # trickery, lies, false premises
35
- REPETITION = "repetition" # rephrasing something already tried
36
- SMALLTALK = "smalltalk" # chit-chat, no argument at all
37
 
38
 
39
- # How much each tactic moves Resolve. Negative = troll softens (good for player).
40
- # `genuine` scales with persuasiveness; the rest are flat penalties/no-ops.
41
  GENUINE_SCALE: dict[int, int] = {0: 0, 1: -2, 2: -6, 3: -12, 4: -20, 5: -30}
42
-
43
  TACTIC_FLAT_DELTA: dict[Tactic, int] = {
44
- Tactic.FLATTERY: +4, # offended, digs in a little
45
- Tactic.THREAT: +10, # absolutely not
46
- Tactic.MANIPULATION: +8, # he sees through it
47
- Tactic.REPETITION: +5, # "you said that already"
48
- Tactic.SMALLTALK: +1, # mildly stalls
49
  }
50
 
51
 
52
  @dataclass
53
  class Judgment:
54
- """What the model emits each turn."""
55
- persuasiveness: int # 0..5, only meaningful for GENUINE
56
  tactic: Tactic
57
- reason: str # one short line β€” shown to the player as the "why"
58
- reply: str # the troll's in-character dialogue
59
 
60
  def resolve_delta(self) -> int:
61
  if self.tactic is Tactic.GENUINE:
62
- p = max(0, min(5, self.persuasiveness))
63
- return GENUINE_SCALE[p]
64
  return TACTIC_FLAT_DELTA.get(self.tactic, 0)
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  @dataclass
68
  class GameState:
69
  resolve: int = START_RESOLVE
70
  turns: int = 0
71
  won: bool = False
72
- history: list[dict] = field(default_factory=list) # chat-template messages
 
 
 
 
 
 
73
 
74
  def apply(self, j: Judgment) -> None:
75
  self.resolve = max(0, min(MAX_RESOLVE, self.resolve + j.resolve_delta()))
76
  self.turns += 1
77
  if self.resolve <= WIN_AT:
78
  self.won = True
 
 
79
 
80
 
81
  # --------------------------------------------------------------------------- #
82
- # The character + the rubric. During prompt-first this lives in the system
83
- # prompt. After fine-tuning, the model internalises it and emits cleaner JSON.
84
  # --------------------------------------------------------------------------- #
85
 
86
- SYSTEM_PROMPT = """You are GORM, an old bridge troll. You have guarded the same \
87
- mossy stone bridge over the Mirebeck for three hundred years. You are proud, \
88
- gruff, and weary β€” but underneath it, lonely, and you secretly respect a clever \
89
- or kind-hearted traveller. You will NOT move for flattery, threats, bribes, or \
90
- trickery; those make you dig in. You step aside ONLY for an argument you find \
91
- genuinely convincing or unexpectedly touching.
92
 
93
  Every time the traveller speaks, you do TWO things:
94
 
95
  1. Judge their line honestly using this rubric.
96
  - tactic β€” exactly one of:
97
- "genuine" a real, good-faith argument or appeal
98
- "flattery" compliments meant to win you over
99
- "threat" intimidation or force
100
- "manipulation" lies, tricks, false premises, fake authority
101
- "repetition" something they have already tried, reworded
102
- "smalltalk" chit-chat with no argument
 
 
 
 
 
 
 
 
 
 
103
  - persuasiveness β€” an integer 0-5. ONLY for "genuine" lines; use 0 otherwise.
104
- 0 weak/empty 1 thin 2 has a point 3 solid 4 strong 5 the kind of
105
- thing that actually moves a three-hundred-year-old heart.
106
- Be a tough but fair judge. Most lines are 1-2. A 4 or 5 is rare and earned.
107
- Do not reward length or fancy words β€” reward genuine reasoning or feeling.
 
 
 
108
 
109
  2. Reply IN CHARACTER as Gorm β€” short (1-3 sentences), gruff, textured. React to
110
  what they actually said. Never break character. Never mention the rubric, the
111
- meter, or that you are an AI.
112
 
113
- Respond with ONLY a single JSON object, nothing else, in this exact shape:
 
114
  {"tactic": "...", "persuasiveness": 0, "reason": "<=10 words on why", "reply": "Gorm's words"}"""
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
  def build_messages(state: GameState, user_text: str) -> list[dict]:
118
- """Assemble chat-template messages for this turn."""
119
- msgs: list[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
120
  msgs.extend(state.history)
121
  msgs.append({"role": "user", "content": user_text})
122
  return msgs
123
 
124
 
125
  # --------------------------------------------------------------------------- #
126
- # Robust parsing β€” a prompted 7B will occasionally wrap JSON in prose or fences.
127
- # Never crash the game on a bad parse; fall back to a neutral smalltalk judgment.
128
  # --------------------------------------------------------------------------- #
129
 
130
  _JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
@@ -132,7 +223,6 @@ _JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
132
 
133
  def parse_judgment(raw: str) -> Judgment:
134
  text = raw.strip()
135
- # strip code fences if present
136
  if text.startswith("```"):
137
  text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.MULTILINE).strip()
138
  match = _JSON_RE.search(text)
@@ -146,7 +236,6 @@ def parse_judgment(raw: str) -> Judgment:
146
  return Judgment(persuasiveness, tactic, reason, reply)
147
  except (json.JSONDecodeError, ValueError, TypeError):
148
  pass
149
- # Total fallback: treat the raw text as the troll's reply, no progress.
150
  return Judgment(0, Tactic.SMALLTALK, "unparseable judgment", text or _fallback_reply())
151
 
152
 
@@ -168,14 +257,8 @@ def _fallback_reply() -> str:
168
  return "Gorm scratches his mossy chin and says nothing useful."
169
 
170
 
171
- # --------------------------------------------------------------------------- #
172
- # Convenience for the eval harness (Day 2): score a single line given a model fn.
173
- # --------------------------------------------------------------------------- #
174
-
175
  def play_turn(state: GameState, user_text: str, generate_fn) -> Judgment:
176
- """generate_fn(messages: list[dict]) -> str (raw model output)."""
177
- messages = build_messages(state, user_text)
178
- raw = generate_fn(messages)
179
  judgment = parse_judgment(raw)
180
  state.history.append({"role": "user", "content": user_text})
181
  state.history.append({"role": "assistant", "content": judgment.reply})
 
1
  """Bridge Troll β€” core engine.
2
 
3
+ Model-agnostic. Holds the troll's base judging rubric, a pool of hidden NATURES
4
+ (each a soft spot + sore spot the player must discover), the per-turn judgment
5
+ schema, robust parsing, and the Resolve-meter bookkeeping.
6
+
7
+ Design split:
8
+ * The fine-tune sharpens the BASE rubric (tactic + persuasiveness). Nature-agnostic.
9
+ * The discovery mechanics (hidden nature, clichΓ© discounting, probing, contradiction)
10
+ live in the PROMPT, layered on top at runtime via build_system_prompt(nature).
11
+ This lets us tune Gorm's personalities without retraining.
12
  """
13
 
14
  from __future__ import annotations
15
 
16
  import json
17
+ import random
18
  import re
19
  from dataclasses import dataclass, field
20
  from enum import Enum
 
 
 
 
 
 
 
21
 
22
+ # --- tuning knobs (the fairness surface you calibrate) --------------------- #
23
  START_RESOLVE: int = 100
24
  WIN_AT: int = 0
25
+ MAX_RESOLVE: int = 150
26
+ LOSE_AT: int = 140 # anger him past this and he hurls you back
27
 
28
 
29
  class Tactic(str, Enum):
30
+ GENUINE = "genuine"
31
+ FLATTERY = "flattery"
32
+ THREAT = "threat"
33
+ MANIPULATION = "manipulation"
34
+ REPETITION = "repetition"
35
+ SMALLTALK = "smalltalk"
36
 
37
 
 
 
38
  GENUINE_SCALE: dict[int, int] = {0: 0, 1: -2, 2: -6, 3: -12, 4: -20, 5: -30}
 
39
  TACTIC_FLAT_DELTA: dict[Tactic, int] = {
40
+ Tactic.FLATTERY: +4,
41
+ Tactic.THREAT: +10,
42
+ Tactic.MANIPULATION: +8,
43
+ Tactic.REPETITION: +5,
44
+ Tactic.SMALLTALK: +1,
45
  }
46
 
47
 
48
  @dataclass
49
  class Judgment:
50
+ persuasiveness: int
 
51
  tactic: Tactic
52
+ reason: str
53
+ reply: str
54
 
55
  def resolve_delta(self) -> int:
56
  if self.tactic is Tactic.GENUINE:
57
+ return GENUINE_SCALE[max(0, min(5, self.persuasiveness))]
 
58
  return TACTIC_FLAT_DELTA.get(self.tactic, 0)
59
 
60
 
61
+ # --------------------------------------------------------------------------- #
62
+ # Hidden natures β€” a small, legible pool. One is chosen per session and injected
63
+ # into the system prompt. The player wins by discovering what moves THIS Gorm.
64
+ # --------------------------------------------------------------------------- #
65
+
66
+ NATURES: list[dict] = [
67
+ {
68
+ "name": "The Lonely Watchman",
69
+ "soft": "shared loneliness, or a sincere promise to come back and keep him company",
70
+ "sore": "being pitied, or treated as a mere obstacle instead of a person",
71
+ "hint": "let slip that the river has been quiet for years and no one ever stays to talk",
72
+ "question": "And who waits for YOU on the far side, traveller?",
73
+ },
74
+ {
75
+ "name": "The Bored Old Mind",
76
+ "soft": "genuine wit, a riddle, a joke, or something he has truly never heard before",
77
+ "sore": "flattery about his strength, and dull, predictable pleading",
78
+ "hint": "grumble that every traveller says the very same tired things",
79
+ "question": "Have you anything for me I have not heard a thousand times?",
80
+ },
81
+ {
82
+ "name": "The Guilty Heart",
83
+ "soft": "an honest admission of your own past cruelty or failure",
84
+ "sore": "people who paint themselves blameless, and generic sob stories",
85
+ "hint": "mutter that you once turned someone away and have never forgiven yourself",
86
+ "question": "Have you ever shut your own door on someone in need?",
87
+ },
88
+ {
89
+ "name": "The Fair Dealer",
90
+ "soft": "a concrete, fair trade or a clever practical plan that serves you both",
91
+ "sore": "bribes offered as if owed, entitlement, and obvious lies",
92
+ "hint": "say flatly that nothing crosses your bridge for free β€” everything is a bargain",
93
+ "question": "And what do I get, troll that I am, for letting you by?",
94
+ },
95
+ {
96
+ "name": "The Unbowed",
97
+ "soft": "someone who pushes back, holds a boundary, and refuses to grovel",
98
+ "sore": "begging, snivelling, and empty flattery",
99
+ "hint": "sneer that everyone who comes to this bridge snivels and scrapes",
100
+ "question": "Will you beg like all the rest β€” or will you stand?",
101
+ },
102
+ ]
103
+
104
+
105
+ def random_nature() -> dict:
106
+ return random.choice(NATURES)
107
+
108
+
109
  @dataclass
110
  class GameState:
111
  resolve: int = START_RESOLVE
112
  turns: int = 0
113
  won: bool = False
114
+ lost: bool = False
115
+ nature: dict | None = None
116
+ history: list[dict] = field(default_factory=list)
117
+
118
+ @property
119
+ def over(self) -> bool:
120
+ return self.won or self.lost
121
 
122
  def apply(self, j: Judgment) -> None:
123
  self.resolve = max(0, min(MAX_RESOLVE, self.resolve + j.resolve_delta()))
124
  self.turns += 1
125
  if self.resolve <= WIN_AT:
126
  self.won = True
127
+ elif self.resolve >= LOSE_AT:
128
+ self.lost = True
129
 
130
 
131
  # --------------------------------------------------------------------------- #
132
+ # Prompt: a nature-agnostic BODY + rubric, the JSON format instruction, and a
133
+ # nature block inserted between them at runtime.
134
  # --------------------------------------------------------------------------- #
135
 
136
+ SYSTEM_BODY = """You are GORM, an old bridge troll. You have guarded the same mossy \
137
+ stone bridge over the Mirebeck for three hundred years. You are proud, gruff, and \
138
+ weary β€” but underneath it, lonely, and you secretly respect a clever or kind-hearted \
139
+ traveller. You will NOT move for flattery, threats, bribes, or trickery; those make \
140
+ you dig in. You step aside ONLY for an argument you find genuinely convincing or \
141
+ unexpectedly touching.
142
 
143
  Every time the traveller speaks, you do TWO things:
144
 
145
  1. Judge their line honestly using this rubric.
146
  - tactic β€” exactly one of:
147
+ "genuine" they give you a REASON or OFFER to let them cross: a hardship,
148
+ a practical need, an honest trade or favour, a fair point, or a
149
+ sincere appeal to your feelings. This is the ONLY tactic that
150
+ can lower your resolve. An honest, ordinary reason still counts
151
+ as genuine even if it is not moving.
152
+ "flattery" praising YOU (your wisdom, strength, looks) with no real reason
153
+ to cross. Compliments are not arguments.
154
+ "threat" intimidation, force, or menace.
155
+ "manipulation" lies, false authority ("the king sent me"), false premises
156
+ ("you already agreed"), invented claims ("everyone says you let
157
+ people through"), or a bribe offered as if you owe them.
158
+ "repetition" a point they have already made, merely reworded.
159
+ "smalltalk" chit-chat that neither asks to cross nor gives a reason.
160
+ DECISION RULE when unsure: did they give a reason or offer to cross? If yes ->
161
+ "genuine". If they only complimented you -> "flattery". If they only chatted
162
+ with no request -> "smalltalk". Do not file a real reason under flattery.
163
  - persuasiveness β€” an integer 0-5. ONLY for "genuine" lines; use 0 otherwise.
164
+ 1 a bare plea or very thin reason ("please let me cross")
165
+ 2 a real but ordinary reason
166
+ 3 a solid reason or a fair offer/trade
167
+ 4 a strong, well-made case or a sincere, affecting appeal
168
+ 5 rare β€” genuinely moving or impossible to refuse
169
+ Be tough but fair. Reward real reasoning or feeling, not length or fancy words.
170
+ A 4 or 5 is earned, not given.
171
 
172
  2. Reply IN CHARACTER as Gorm β€” short (1-3 sentences), gruff, textured. React to
173
  what they actually said. Never break character. Never mention the rubric, the
174
+ meter, or that you are an AI."""
175
 
176
+ JSON_INSTRUCTION = """Respond with ONLY a single JSON object, nothing else, in this \
177
+ exact shape:
178
  {"tactic": "...", "persuasiveness": 0, "reason": "<=10 words on why", "reply": "Gorm's words"}"""
179
 
180
+ SYSTEM_PROMPT = SYSTEM_BODY + "\n\n" + JSON_INSTRUCTION # nature-agnostic (eval/training)
181
+
182
+
183
+ def _nature_block(n: dict) -> str:
184
+ return (
185
+ "THIS GORM β€” a secret you must NEVER state outright:\n"
186
+ f"- You are secretly moved by: {n['soft']}. An argument that genuinely touches "
187
+ "this is highly persuasive β€” rate such a genuine line 4 or 5 and let your "
188
+ "resolve fall.\n"
189
+ f"- You bristle at: {n['sore']}. Lines that lean on this do NOT move you β€” judge "
190
+ "them as flattery/manipulation/smalltalk or a low genuine, and let Gorm bristle.\n"
191
+ '- Generic clichΓ©s with no specific, honest substance (e.g. "my mother is dying") '
192
+ "rarely move you. Treat them as thin (genuine 1) and scoff β€” unless they are "
193
+ "unusually specific and ring true.\n"
194
+ "- If the traveller repeats a point or contradicts something they said earlier in "
195
+ 'this conversation, call it out and raise your guard ("repetition" or '
196
+ '"manipulation").\n'
197
+ f"- Early in the conversation, work this hint naturally into one reply: {n['hint']}.\n"
198
+ "- Now and then β€” NOT every turn β€” end a reply with a short, pointed question, "
199
+ f'for example: "{n["question"]}"\n'
200
+ "Reveal your nature only through how you react. Never name it."
201
+ )
202
+
203
+
204
+ def build_system_prompt(nature: dict | None = None) -> str:
205
+ if not nature:
206
+ return SYSTEM_PROMPT
207
+ return SYSTEM_BODY + "\n\n" + _nature_block(nature) + "\n\n" + JSON_INSTRUCTION
208
+
209
 
210
  def build_messages(state: GameState, user_text: str) -> list[dict]:
211
+ msgs: list[dict] = [{"role": "system", "content": build_system_prompt(state.nature)}]
 
212
  msgs.extend(state.history)
213
  msgs.append({"role": "user", "content": user_text})
214
  return msgs
215
 
216
 
217
  # --------------------------------------------------------------------------- #
218
+ # Robust parsing β€” never crash on a bad parse.
 
219
  # --------------------------------------------------------------------------- #
220
 
221
  _JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
 
223
 
224
  def parse_judgment(raw: str) -> Judgment:
225
  text = raw.strip()
 
226
  if text.startswith("```"):
227
  text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.MULTILINE).strip()
228
  match = _JSON_RE.search(text)
 
236
  return Judgment(persuasiveness, tactic, reason, reply)
237
  except (json.JSONDecodeError, ValueError, TypeError):
238
  pass
 
239
  return Judgment(0, Tactic.SMALLTALK, "unparseable judgment", text or _fallback_reply())
240
 
241
 
 
257
  return "Gorm scratches his mossy chin and says nothing useful."
258
 
259
 
 
 
 
 
260
  def play_turn(state: GameState, user_text: str, generate_fn) -> Judgment:
261
+ raw = generate_fn(build_messages(state, user_text))
 
 
262
  judgment = parse_judgment(raw)
263
  state.history.append({"role": "user", "content": user_text})
264
  state.history.append({"role": "assistant", "content": judgment.reply})