Spaces:
Sleeping
Sleeping
王致渊 commited on
Commit ·
57e0211
0
Parent(s):
Initial commit
Browse files- .gitignore +7 -0
- README.md +10 -0
- app.py +377 -0
- pyproject.toml +20 -0
- requirements.txt +6 -0
- scripts/evaluate.py +82 -0
- scripts/generate_case.py +60 -0
- scripts/play_text.py +84 -0
- tests/__init__.py +0 -0
- tests/test_engine.py +222 -0
- tests/test_gym_env.py +106 -0
- tests/test_schema.py +244 -0
- tests/test_text_env.py +137 -0
- tests/test_validator.py +863 -0
- turnabout/__init__.py +3 -0
- turnabout/agents/__init__.py +0 -0
- turnabout/agents/llm_agent.py +96 -0
- turnabout/agents/random_agent.py +81 -0
- turnabout/cases/stolen_prototype.json +395 -0
- turnabout/core/__init__.py +0 -0
- turnabout/core/actions.py +228 -0
- turnabout/core/engine.py +617 -0
- turnabout/core/schema.py +187 -0
- turnabout/core/scoring.py +126 -0
- turnabout/core/state.py +73 -0
- turnabout/envs/__init__.py +15 -0
- turnabout/envs/gym_env.py +181 -0
- turnabout/envs/renderer.py +141 -0
- turnabout/envs/text_env.py +105 -0
- turnabout/generation/__init__.py +5 -0
- turnabout/generation/generator.py +386 -0
- turnabout/generation/prompts.py +315 -0
- turnabout/generation/validator.py +575 -0
- turnabout/i18n.py +237 -0
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.venv/
|
| 3 |
+
.claude/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
*.egg-info/
|
| 7 |
+
.pytest_cache/
|
README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Turnabout Bench
|
| 3 |
+
emoji: "⚖"
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 6.15.2
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
---
|
app.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Gradio web UI for Turnabout — Ace Attorney-style agent evaluation environment."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import glob
|
| 8 |
+
import asyncio
|
| 9 |
+
import re as _re
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import gradio as gr
|
| 13 |
+
|
| 14 |
+
from turnabout.envs.text_env import TextCourtEnv
|
| 15 |
+
from turnabout.i18n import t
|
| 16 |
+
|
| 17 |
+
CASES_DIR = Path(__file__).parent / "turnabout" / "cases"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def list_cases() -> list[str]:
|
| 21 |
+
return sorted(glob.glob(str(CASES_DIR / "*.json")))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def load_case_titles() -> dict[str, str]:
|
| 25 |
+
titles = {}
|
| 26 |
+
for path in list_cases():
|
| 27 |
+
try:
|
| 28 |
+
with open(path) as f:
|
| 29 |
+
data = json.load(f)
|
| 30 |
+
titles[data.get("title", Path(path).stem)] = path
|
| 31 |
+
except Exception:
|
| 32 |
+
titles[Path(path).stem] = path
|
| 33 |
+
return titles
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class GameSession:
|
| 37 |
+
def __init__(self):
|
| 38 |
+
self.env: TextCourtEnv | None = None
|
| 39 |
+
self.done = False
|
| 40 |
+
self.lang = "en"
|
| 41 |
+
|
| 42 |
+
def start(self, case_path: str, difficulty: str, lang: str) -> str:
|
| 43 |
+
self.lang = lang
|
| 44 |
+
self.env = TextCourtEnv(case_path=case_path, difficulty=difficulty, max_steps=200, lang=lang)
|
| 45 |
+
obs = self.env.reset()
|
| 46 |
+
self.done = False
|
| 47 |
+
return obs
|
| 48 |
+
|
| 49 |
+
def step(self, action: str) -> tuple[str, float, bool]:
|
| 50 |
+
if self.env is None or self.done:
|
| 51 |
+
return t("ui_no_game", self.lang), 0.0, True
|
| 52 |
+
obs, reward, done, info = self.env.step(action)
|
| 53 |
+
self.done = done
|
| 54 |
+
return obs, reward, done
|
| 55 |
+
|
| 56 |
+
def get_valid_action_choices(self) -> list[tuple[str, str]]:
|
| 57 |
+
if self.env is None or self.done:
|
| 58 |
+
return []
|
| 59 |
+
valid = self.env.engine.get_valid_actions()
|
| 60 |
+
return [(a.display(self.lang, self.env.case), str(a)) for a in valid]
|
| 61 |
+
|
| 62 |
+
def get_evidence_list(self) -> str:
|
| 63 |
+
if self.env is None:
|
| 64 |
+
return t("ui_no_game", self.lang)
|
| 65 |
+
state = self.env.engine.state
|
| 66 |
+
if not state or not state.inventory:
|
| 67 |
+
return t("ui_no_evidence", self.lang)
|
| 68 |
+
lines = []
|
| 69 |
+
for i, eid in enumerate(sorted(state.inventory), 1):
|
| 70 |
+
ev = self.env.case.get_evidence(eid)
|
| 71 |
+
if ev:
|
| 72 |
+
desc = ev.detail if ev.detail else ev.description
|
| 73 |
+
lines.append(f"**{i}. {ev.name}**\n {desc}")
|
| 74 |
+
return "\n\n".join(lines)
|
| 75 |
+
|
| 76 |
+
def get_status(self) -> str:
|
| 77 |
+
if self.env is None:
|
| 78 |
+
return t("ui_no_game", self.lang)
|
| 79 |
+
state = self.env.engine.state
|
| 80 |
+
if not state:
|
| 81 |
+
return t("ui_no_game", self.lang)
|
| 82 |
+
phase_key = f"phase_{state.phase.value}"
|
| 83 |
+
phase = t(phase_key, self.lang)
|
| 84 |
+
penalties = f"{state.penalties}/{self.env.case.court.penalty_limit}"
|
| 85 |
+
found = len(state.contradictions_found)
|
| 86 |
+
required = len(self.env.case.court.win_condition.required_contradiction_ids)
|
| 87 |
+
return (
|
| 88 |
+
f"**{t('ui_phase', self.lang)}:** {phase}\n\n"
|
| 89 |
+
f"**{t('ui_penalties', self.lang)}:** {penalties}\n\n"
|
| 90 |
+
f"**{t('ui_contradictions', self.lang)}:** {found}/{required}\n\n"
|
| 91 |
+
f"**{t('ui_steps', self.lang)}:** {state.step_count}"
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
def get_metrics_text(self) -> str:
|
| 95 |
+
if self.env is None:
|
| 96 |
+
return ""
|
| 97 |
+
metrics = self.env.get_metrics()
|
| 98 |
+
return (
|
| 99 |
+
f"**{t('ui_result', self.lang)}:** {t('ui_won', self.lang) if metrics.won else t('ui_lost', self.lang)}\n\n"
|
| 100 |
+
f"**{t('ui_contradiction_accuracy', self.lang)}:** {metrics.contradiction_accuracy:.1%}\n\n"
|
| 101 |
+
f"**{t('ui_evidence_coverage', self.lang)}:** {metrics.evidence_coverage:.1%}\n\n"
|
| 102 |
+
f"**{t('ui_steps', self.lang)}:** {metrics.total_steps}\n\n"
|
| 103 |
+
f"**{t('ui_composite_score', self.lang)}:** {metrics.composite_score:.3f}"
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
session = GameSession()
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _msg(role: str, content: str) -> dict:
|
| 111 |
+
return {"role": role, "content": content}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _lang_code(lang_label: str) -> str:
|
| 115 |
+
return "zh" if lang_label == "中文" else "en"
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def on_new_game(case_title: str, difficulty: str, lang_label: str) -> tuple:
|
| 119 |
+
lang = _lang_code(lang_label)
|
| 120 |
+
titles = load_case_titles()
|
| 121 |
+
case_path = titles.get(case_title)
|
| 122 |
+
if not case_path:
|
| 123 |
+
cases = list_cases()
|
| 124 |
+
if not cases:
|
| 125 |
+
return [_msg("assistant", t("ui_no_cases", lang))], "", "", gr.update(choices=[], value=None), gr.update(interactive=True)
|
| 126 |
+
case_path = cases[0]
|
| 127 |
+
|
| 128 |
+
obs = session.start(case_path, difficulty, lang)
|
| 129 |
+
history = [_msg("assistant", obs)]
|
| 130 |
+
choices = session.get_valid_action_choices()
|
| 131 |
+
return (
|
| 132 |
+
history,
|
| 133 |
+
session.get_evidence_list(),
|
| 134 |
+
session.get_status(),
|
| 135 |
+
gr.update(choices=choices, value=choices[0][1] if choices else None),
|
| 136 |
+
gr.update(interactive=True),
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def on_execute(action_str: str, chat_history: list, lang_label: str) -> tuple:
|
| 141 |
+
lang = _lang_code(lang_label)
|
| 142 |
+
if not action_str:
|
| 143 |
+
choices = session.get_valid_action_choices()
|
| 144 |
+
return chat_history, session.get_evidence_list(), session.get_status(), gr.update(choices=choices, value=choices[0][1] if choices else None), gr.update(visible=False), ""
|
| 145 |
+
|
| 146 |
+
display_name = action_str
|
| 147 |
+
choices = session.get_valid_action_choices()
|
| 148 |
+
for label, val in choices:
|
| 149 |
+
if val == action_str:
|
| 150 |
+
display_name = label
|
| 151 |
+
break
|
| 152 |
+
|
| 153 |
+
obs, reward, done = session.step(action_str)
|
| 154 |
+
|
| 155 |
+
reward_text = f"\n\n`[{t('ui_reward', lang)}: {reward:+.2f}]`" if reward != 0 else ""
|
| 156 |
+
response = obs + reward_text
|
| 157 |
+
|
| 158 |
+
chat_history = chat_history + [
|
| 159 |
+
_msg("user", display_name),
|
| 160 |
+
_msg("assistant", response),
|
| 161 |
+
]
|
| 162 |
+
|
| 163 |
+
if done:
|
| 164 |
+
metrics_text = session.get_metrics_text()
|
| 165 |
+
chat_history = chat_history + [_msg("assistant", f"---\n**{t('ui_game_over', lang)}**\n\n{metrics_text}")]
|
| 166 |
+
return chat_history, session.get_evidence_list(), session.get_status(), gr.update(choices=[], value=None), gr.update(visible=True), metrics_text
|
| 167 |
+
|
| 168 |
+
new_choices = session.get_valid_action_choices()
|
| 169 |
+
return (
|
| 170 |
+
chat_history,
|
| 171 |
+
session.get_evidence_list(),
|
| 172 |
+
session.get_status(),
|
| 173 |
+
gr.update(choices=new_choices, value=new_choices[0][1] if new_choices else None),
|
| 174 |
+
gr.update(visible=False),
|
| 175 |
+
"",
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def on_generate_case(theme: str, gen_difficulty: str, progress=gr.Progress()) -> str:
|
| 180 |
+
try:
|
| 181 |
+
from dotenv import load_dotenv
|
| 182 |
+
load_dotenv()
|
| 183 |
+
except ImportError:
|
| 184 |
+
pass
|
| 185 |
+
|
| 186 |
+
try:
|
| 187 |
+
from turnabout.generation.generator import CaseGenerator
|
| 188 |
+
except ImportError as e:
|
| 189 |
+
return f"Import error: {e}"
|
| 190 |
+
|
| 191 |
+
progress(0.1, desc="Initializing generator...")
|
| 192 |
+
|
| 193 |
+
try:
|
| 194 |
+
generator = CaseGenerator(backend="openai")
|
| 195 |
+
except Exception as e:
|
| 196 |
+
return f"Failed to create generator: {e}"
|
| 197 |
+
|
| 198 |
+
progress(0.2, desc="Generating case (this may take a minute)...")
|
| 199 |
+
|
| 200 |
+
try:
|
| 201 |
+
case = asyncio.run(generator.generate(
|
| 202 |
+
theme=theme or None,
|
| 203 |
+
difficulty=gen_difficulty,
|
| 204 |
+
))
|
| 205 |
+
except Exception as e:
|
| 206 |
+
return f"Generation failed: {e}"
|
| 207 |
+
|
| 208 |
+
progress(0.9, desc="Saving case...")
|
| 209 |
+
|
| 210 |
+
slug = _re.sub(r"[^a-z0-9]+", "_", case.title.lower()).strip("_")
|
| 211 |
+
out_path = CASES_DIR / f"{slug}.json"
|
| 212 |
+
with open(out_path, "w") as f:
|
| 213 |
+
json.dump(case.model_dump(), f, indent=2, ensure_ascii=False)
|
| 214 |
+
|
| 215 |
+
return f"Case generated successfully!\n\n**{case.title}**\n{case.description}\n\nSaved to: `{out_path.name}`\n\nRefresh the page to see the new case in the Play tab."
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
COMMANDS_HELP_EN = """
|
| 219 |
+
### Investigation Commands
|
| 220 |
+
- `move <location>` — Travel to a location
|
| 221 |
+
- `examine <object>` — Examine an object
|
| 222 |
+
- `talk <person>` — Talk to a character
|
| 223 |
+
- `present <evidence> to <person>` — Show evidence to someone
|
| 224 |
+
- `go to court` — Proceed to court
|
| 225 |
+
|
| 226 |
+
### Court Commands
|
| 227 |
+
- `press` — Press the witness on current statement
|
| 228 |
+
- `present <evidence>` — Present evidence against current statement
|
| 229 |
+
- `next` / `prev` — Navigate between statements
|
| 230 |
+
"""
|
| 231 |
+
|
| 232 |
+
COMMANDS_HELP_ZH = """
|
| 233 |
+
### 调查阶段命令
|
| 234 |
+
- `move <地点>` — 前往某地点
|
| 235 |
+
- `examine <物品>` — 调查物品
|
| 236 |
+
- `talk <人物>` — 与人物对话
|
| 237 |
+
- `present <证据> to <人物>` — 向人物出示证据
|
| 238 |
+
- `go to court` — 前往法庭
|
| 239 |
+
|
| 240 |
+
### 法庭命令
|
| 241 |
+
- `press` — 追问当前证言
|
| 242 |
+
- `present <证据>` — 出示证据反驳当前证言
|
| 243 |
+
- `next` / `prev` — 切换证言
|
| 244 |
+
"""
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def build_app():
|
| 248 |
+
case_titles = load_case_titles()
|
| 249 |
+
case_choices = list(case_titles.keys()) if case_titles else ["No cases found"]
|
| 250 |
+
|
| 251 |
+
with gr.Blocks(title="Turnabout Bench") as app:
|
| 252 |
+
gr.Markdown(t("ui_title", "en"), elem_id="app_title")
|
| 253 |
+
|
| 254 |
+
lang_radio = gr.Radio(
|
| 255 |
+
choices=["English", "中文"],
|
| 256 |
+
value="English",
|
| 257 |
+
label="Language / 语言",
|
| 258 |
+
interactive=True,
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
with gr.Tabs():
|
| 262 |
+
with gr.Tab("Play", id="play"):
|
| 263 |
+
with gr.Row():
|
| 264 |
+
with gr.Column(scale=3):
|
| 265 |
+
chatbot = gr.Chatbot(
|
| 266 |
+
label=t("ui_game_label", "en"),
|
| 267 |
+
height=480,
|
| 268 |
+
)
|
| 269 |
+
action_radio = gr.Radio(
|
| 270 |
+
choices=[],
|
| 271 |
+
label=t("ui_select_action", "en"),
|
| 272 |
+
interactive=True,
|
| 273 |
+
)
|
| 274 |
+
execute_btn = gr.Button(t("ui_execute", "en"), variant="primary")
|
| 275 |
+
|
| 276 |
+
with gr.Column(scale=1):
|
| 277 |
+
with gr.Group():
|
| 278 |
+
case_dropdown = gr.Dropdown(
|
| 279 |
+
choices=case_choices,
|
| 280 |
+
value=case_choices[0] if case_choices else None,
|
| 281 |
+
label=t("ui_case", "en"),
|
| 282 |
+
)
|
| 283 |
+
difficulty_radio = gr.Radio(
|
| 284 |
+
choices=["easy", "hard"],
|
| 285 |
+
value="easy",
|
| 286 |
+
label=t("ui_difficulty", "en"),
|
| 287 |
+
)
|
| 288 |
+
new_game_btn = gr.Button(t("ui_new_game", "en"), variant="primary")
|
| 289 |
+
|
| 290 |
+
status_md = gr.Markdown(
|
| 291 |
+
t("ui_click_new_game", "en"),
|
| 292 |
+
label=t("ui_status", "en"),
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
evidence_md = gr.Markdown(
|
| 296 |
+
"",
|
| 297 |
+
label=t("ui_evidence", "en"),
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
metrics_box = gr.Markdown("", visible=False, label="Final Metrics")
|
| 301 |
+
|
| 302 |
+
with gr.Accordion(t("ui_commands_ref", "en"), open=False):
|
| 303 |
+
gr.Markdown(COMMANDS_HELP_EN)
|
| 304 |
+
|
| 305 |
+
new_game_btn.click(
|
| 306 |
+
fn=on_new_game,
|
| 307 |
+
inputs=[case_dropdown, difficulty_radio, lang_radio],
|
| 308 |
+
outputs=[chatbot, evidence_md, status_md, action_radio, execute_btn],
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
execute_btn.click(
|
| 312 |
+
fn=on_execute,
|
| 313 |
+
inputs=[action_radio, chatbot, lang_radio],
|
| 314 |
+
outputs=[chatbot, evidence_md, status_md, action_radio, metrics_box, metrics_box],
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
with gr.Tab("Generate Case", id="generate"):
|
| 318 |
+
gr.Markdown(
|
| 319 |
+
"### LLM-Powered Case Generation\n"
|
| 320 |
+
"Use the configured LLM gateway to generate new cases automatically."
|
| 321 |
+
)
|
| 322 |
+
with gr.Row():
|
| 323 |
+
with gr.Column():
|
| 324 |
+
theme_input = gr.Textbox(
|
| 325 |
+
label=t("ui_theme", "en"),
|
| 326 |
+
placeholder="e.g. 'art heist', 'corporate espionage', 'poisoned cake'",
|
| 327 |
+
)
|
| 328 |
+
gen_difficulty = gr.Radio(
|
| 329 |
+
choices=["easy", "hard"],
|
| 330 |
+
value="easy",
|
| 331 |
+
label=t("ui_difficulty", "en"),
|
| 332 |
+
)
|
| 333 |
+
generate_btn = gr.Button(t("ui_generate_btn", "en"), variant="primary")
|
| 334 |
+
with gr.Column():
|
| 335 |
+
gen_output = gr.Markdown(label=t("ui_result", "en"))
|
| 336 |
+
|
| 337 |
+
generate_btn.click(
|
| 338 |
+
fn=on_generate_case,
|
| 339 |
+
inputs=[theme_input, gen_difficulty],
|
| 340 |
+
outputs=[gen_output],
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
with gr.Tab("About", id="about"):
|
| 344 |
+
gr.Markdown("""
|
| 345 |
+
### What is Turnabout Bench?
|
| 346 |
+
|
| 347 |
+
Turnabout Bench is an interactive evaluation environment for testing agent long-horizon reasoning,
|
| 348 |
+
inspired by the **Ace Attorney** game series.
|
| 349 |
+
|
| 350 |
+
**Key Features:**
|
| 351 |
+
- **Investigation Phase** (hard mode): Explore locations, collect evidence, talk to witnesses
|
| 352 |
+
- **Court Phase**: Cross-examine witnesses, find contradictions in testimony
|
| 353 |
+
- **Dual Interface**: Text-based for LLM agents, Gymnasium-compatible for RL agents
|
| 354 |
+
- **Evaluation Metrics**: Contradiction accuracy, evidence coverage, step efficiency
|
| 355 |
+
- **LLM Case Generation**: Automatically generate new cases via API
|
| 356 |
+
|
| 357 |
+
**Usage:**
|
| 358 |
+
```python
|
| 359 |
+
# LLM Agent
|
| 360 |
+
from turnabout.envs.text_env import TextCourtEnv
|
| 361 |
+
env = TextCourtEnv(case_path="...", difficulty="easy")
|
| 362 |
+
obs = env.reset()
|
| 363 |
+
obs, reward, done, info = env.step("present evidence_name")
|
| 364 |
+
|
| 365 |
+
# RL Agent (Gymnasium)
|
| 366 |
+
import gymnasium as gym
|
| 367 |
+
import turnabout.envs
|
| 368 |
+
env = gym.make("Turnabout-Discrete-v0", case_path="...")
|
| 369 |
+
```
|
| 370 |
+
""")
|
| 371 |
+
|
| 372 |
+
return app
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
if __name__ == "__main__":
|
| 376 |
+
app = build_app()
|
| 377 |
+
app.launch(share=False)
|
pyproject.toml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "turnabout"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Ace Attorney-inspired environment for evaluating LLM/RL agent long-horizon reasoning"
|
| 5 |
+
requires-python = ">=3.10"
|
| 6 |
+
dependencies = [
|
| 7 |
+
"pydantic>=2.0",
|
| 8 |
+
"gymnasium>=0.29",
|
| 9 |
+
"numpy>=1.24",
|
| 10 |
+
]
|
| 11 |
+
|
| 12 |
+
[project.optional-dependencies]
|
| 13 |
+
generation = ["openai>=1.0"]
|
| 14 |
+
agents = ["anthropic>=0.40", "openai>=1.0"]
|
| 15 |
+
ui = ["gradio>=4.0", "python-dotenv>=1.0"]
|
| 16 |
+
dev = ["pytest>=7.0", "pytest-asyncio>=0.21"]
|
| 17 |
+
|
| 18 |
+
[build-system]
|
| 19 |
+
requires = ["setuptools>=68.0"]
|
| 20 |
+
build-backend = "setuptools.build_meta"
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pydantic>=2.0
|
| 2 |
+
gymnasium>=0.29
|
| 3 |
+
numpy>=1.24
|
| 4 |
+
gradio>=6.15
|
| 5 |
+
python-dotenv>=1.0
|
| 6 |
+
openai>=1.0
|
scripts/evaluate.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Batch evaluation runner for agents."""
|
| 3 |
+
|
| 4 |
+
import argparse
|
| 5 |
+
import json
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 10 |
+
|
| 11 |
+
from turnabout.agents.random_agent import RandomAgent, run_benchmark
|
| 12 |
+
from turnabout.envs.text_env import TextCourtEnv
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def main():
|
| 16 |
+
parser = argparse.ArgumentParser(description="Evaluate agents on Turnabout cases")
|
| 17 |
+
parser.add_argument(
|
| 18 |
+
"case",
|
| 19 |
+
nargs="?",
|
| 20 |
+
default=str(Path(__file__).parent.parent / "turnabout" / "cases" / "stolen_prototype.json"),
|
| 21 |
+
help="Path to case JSON file",
|
| 22 |
+
)
|
| 23 |
+
parser.add_argument("-d", "--difficulty", choices=["easy", "hard"], default="easy")
|
| 24 |
+
parser.add_argument("-n", "--episodes", type=int, default=100)
|
| 25 |
+
parser.add_argument("--agent", choices=["random", "llm"], default="random")
|
| 26 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 27 |
+
parser.add_argument("-v", "--verbose", action="store_true")
|
| 28 |
+
parser.add_argument("-o", "--output", help="Save results to JSON file")
|
| 29 |
+
args = parser.parse_args()
|
| 30 |
+
|
| 31 |
+
print(f"Evaluating {args.agent} agent on {Path(args.case).stem}")
|
| 32 |
+
print(f"Difficulty: {args.difficulty}, Episodes: {args.episodes}")
|
| 33 |
+
print()
|
| 34 |
+
|
| 35 |
+
if args.agent == "random":
|
| 36 |
+
results = run_benchmark(
|
| 37 |
+
case_path=args.case,
|
| 38 |
+
difficulty=args.difficulty,
|
| 39 |
+
n_episodes=args.episodes,
|
| 40 |
+
seed=args.seed,
|
| 41 |
+
verbose=args.verbose,
|
| 42 |
+
)
|
| 43 |
+
elif args.agent == "llm":
|
| 44 |
+
try:
|
| 45 |
+
from turnabout.agents.llm_agent import LLMAgent
|
| 46 |
+
except ImportError as e:
|
| 47 |
+
print(f"Error: {e}")
|
| 48 |
+
sys.exit(1)
|
| 49 |
+
|
| 50 |
+
agent = LLMAgent()
|
| 51 |
+
results_list = []
|
| 52 |
+
for i in range(args.episodes):
|
| 53 |
+
env = TextCourtEnv(case_path=args.case, difficulty=args.difficulty)
|
| 54 |
+
result = agent.run_episode(env, verbose=args.verbose and i == 0)
|
| 55 |
+
results_list.append(result)
|
| 56 |
+
print(f" Episode {i + 1}: {'WON' if result['won'] else 'LOST'} "
|
| 57 |
+
f"(score={result['composite_score']:.3f})")
|
| 58 |
+
|
| 59 |
+
wins = sum(r["won"] for r in results_list)
|
| 60 |
+
results = {
|
| 61 |
+
"n_episodes": args.episodes,
|
| 62 |
+
"win_rate": wins / args.episodes,
|
| 63 |
+
"avg_reward": sum(r["total_reward"] for r in results_list) / args.episodes,
|
| 64 |
+
"avg_steps": sum(r["steps"] for r in results_list) / args.episodes,
|
| 65 |
+
"avg_composite_score": sum(r["composite_score"] for r in results_list) / args.episodes,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
print("Results:")
|
| 69 |
+
for k, v in results.items():
|
| 70 |
+
if isinstance(v, float):
|
| 71 |
+
print(f" {k}: {v:.4f}")
|
| 72 |
+
else:
|
| 73 |
+
print(f" {k}: {v}")
|
| 74 |
+
|
| 75 |
+
if args.output:
|
| 76 |
+
with open(args.output, "w") as f:
|
| 77 |
+
json.dump(results, f, indent=2)
|
| 78 |
+
print(f"\nSaved to {args.output}")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
if __name__ == "__main__":
|
| 82 |
+
main()
|
scripts/generate_case.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""CLI for LLM-based case generation."""
|
| 3 |
+
|
| 4 |
+
import argparse
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def main():
|
| 14 |
+
parser = argparse.ArgumentParser(description="Generate a new Turnabout case using LLM")
|
| 15 |
+
parser.add_argument("-t", "--theme", default=None, help="Theme for the case (e.g. 'art heist')")
|
| 16 |
+
parser.add_argument("-d", "--difficulty", choices=["easy", "hard"], default="easy")
|
| 17 |
+
parser.add_argument("-m", "--model", default="claude-sonnet-4-6")
|
| 18 |
+
parser.add_argument("-o", "--output", required=True, help="Output JSON file path")
|
| 19 |
+
parser.add_argument("--validate-only", help="Only validate an existing case file")
|
| 20 |
+
args = parser.parse_args()
|
| 21 |
+
|
| 22 |
+
if args.validate_only:
|
| 23 |
+
from turnabout.core.schema import CaseData
|
| 24 |
+
from turnabout.generation.validator import CaseValidator
|
| 25 |
+
|
| 26 |
+
with open(args.validate_only) as f:
|
| 27 |
+
data = json.load(f)
|
| 28 |
+
case = CaseData(**data)
|
| 29 |
+
validator = CaseValidator()
|
| 30 |
+
report = validator.validate(case)
|
| 31 |
+
print(f"Valid: {report.is_valid}")
|
| 32 |
+
for e in report.errors:
|
| 33 |
+
print(f" ERROR: {e}")
|
| 34 |
+
for w in report.warnings:
|
| 35 |
+
print(f" WARNING: {w}")
|
| 36 |
+
return
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
from turnabout.generation.generator import CaseGenerator
|
| 40 |
+
except ImportError as e:
|
| 41 |
+
print(f"Error: {e}")
|
| 42 |
+
print("Install with: pip install 'turnabout[generation]'")
|
| 43 |
+
sys.exit(1)
|
| 44 |
+
|
| 45 |
+
generator = CaseGenerator(model=args.model)
|
| 46 |
+
print(f"Generating case (theme={args.theme}, difficulty={args.difficulty})...")
|
| 47 |
+
|
| 48 |
+
case = await generator.generate(theme=args.theme, difficulty=args.difficulty)
|
| 49 |
+
|
| 50 |
+
with open(args.output, "w") as f:
|
| 51 |
+
json.dump(case.model_dump(), f, indent=2, ensure_ascii=False)
|
| 52 |
+
|
| 53 |
+
print(f"Case saved to {args.output}")
|
| 54 |
+
print(f"Title: {case.title}")
|
| 55 |
+
print(f"Characters: {len(case.characters)}")
|
| 56 |
+
print(f"Evidence: {len(case.evidence)}")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
if __name__ == "__main__":
|
| 60 |
+
asyncio.run(main())
|
scripts/play_text.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Interactive text CLI for human play-testing."""
|
| 3 |
+
|
| 4 |
+
import argparse
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 9 |
+
|
| 10 |
+
from turnabout.envs.text_env import TextCourtEnv
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def main():
|
| 14 |
+
parser = argparse.ArgumentParser(description="Play a Turnabout case interactively")
|
| 15 |
+
parser.add_argument(
|
| 16 |
+
"case",
|
| 17 |
+
nargs="?",
|
| 18 |
+
default=str(Path(__file__).parent.parent / "turnabout" / "cases" / "stolen_prototype.json"),
|
| 19 |
+
help="Path to case JSON file",
|
| 20 |
+
)
|
| 21 |
+
parser.add_argument(
|
| 22 |
+
"-d", "--difficulty",
|
| 23 |
+
choices=["easy", "hard"],
|
| 24 |
+
default=None,
|
| 25 |
+
help="Override case difficulty",
|
| 26 |
+
)
|
| 27 |
+
args = parser.parse_args()
|
| 28 |
+
|
| 29 |
+
env = TextCourtEnv(case_path=args.case, difficulty=args.difficulty)
|
| 30 |
+
|
| 31 |
+
print("=" * 60)
|
| 32 |
+
print(" TURNABOUT - Interactive Court Game")
|
| 33 |
+
print("=" * 60)
|
| 34 |
+
print()
|
| 35 |
+
print(env.system_prompt)
|
| 36 |
+
print()
|
| 37 |
+
print("Type 'quit' to exit, 'help' for commands, 'status' for evidence.")
|
| 38 |
+
print("=" * 60)
|
| 39 |
+
print()
|
| 40 |
+
|
| 41 |
+
obs = env.reset()
|
| 42 |
+
print(obs)
|
| 43 |
+
|
| 44 |
+
while True:
|
| 45 |
+
print()
|
| 46 |
+
try:
|
| 47 |
+
action = input(">> ").strip()
|
| 48 |
+
except (EOFError, KeyboardInterrupt):
|
| 49 |
+
print("\nGoodbye!")
|
| 50 |
+
break
|
| 51 |
+
|
| 52 |
+
if not action:
|
| 53 |
+
continue
|
| 54 |
+
if action.lower() == "quit":
|
| 55 |
+
print("Goodbye!")
|
| 56 |
+
break
|
| 57 |
+
if action.lower() == "help":
|
| 58 |
+
print(env.system_prompt)
|
| 59 |
+
continue
|
| 60 |
+
if action.lower() == "status":
|
| 61 |
+
print(env.render())
|
| 62 |
+
continue
|
| 63 |
+
|
| 64 |
+
obs, reward, done, info = env.step(action)
|
| 65 |
+
print(obs)
|
| 66 |
+
|
| 67 |
+
if reward != 0:
|
| 68 |
+
print(f" [Reward: {reward:+.2f}]")
|
| 69 |
+
|
| 70 |
+
if done:
|
| 71 |
+
print()
|
| 72 |
+
print("=" * 60)
|
| 73 |
+
metrics = env.get_metrics()
|
| 74 |
+
print(f" Result: {'WON' if metrics.won else 'LOST'}")
|
| 75 |
+
print(f" Steps: {metrics.total_steps}")
|
| 76 |
+
print(f" Contradiction Accuracy: {metrics.contradiction_accuracy:.1%}")
|
| 77 |
+
print(f" Evidence Coverage: {metrics.evidence_coverage:.1%}")
|
| 78 |
+
print(f" Composite Score: {metrics.composite_score:.3f}")
|
| 79 |
+
print("=" * 60)
|
| 80 |
+
break
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
if __name__ == "__main__":
|
| 84 |
+
main()
|
tests/__init__.py
ADDED
|
File without changes
|
tests/test_engine.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the core game engine."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from turnabout.core.actions import Action, ActionType
|
| 9 |
+
from turnabout.core.engine import TurnaboutEngine
|
| 10 |
+
from turnabout.core.schema import CaseData
|
| 11 |
+
from turnabout.core.scoring import MetricsComputer
|
| 12 |
+
from turnabout.core.state import GamePhase
|
| 13 |
+
|
| 14 |
+
CASES_DIR = Path(__file__).parent.parent / "turnabout" / "cases"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@pytest.fixture
|
| 18 |
+
def case():
|
| 19 |
+
path = CASES_DIR / "stolen_prototype.json"
|
| 20 |
+
if not path.exists():
|
| 21 |
+
pytest.skip("stolen_prototype.json not yet created")
|
| 22 |
+
with open(path) as f:
|
| 23 |
+
return CaseData(**json.load(f))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class TestEasyMode:
|
| 27 |
+
def test_starts_in_court(self, case):
|
| 28 |
+
engine = TurnaboutEngine(case, difficulty="easy")
|
| 29 |
+
state, obs = engine.reset()
|
| 30 |
+
assert state.phase == GamePhase.COURT_TESTIMONY
|
| 31 |
+
assert "COURT" in obs
|
| 32 |
+
|
| 33 |
+
def test_all_evidence_available(self, case):
|
| 34 |
+
engine = TurnaboutEngine(case, difficulty="easy")
|
| 35 |
+
state, _ = engine.reset()
|
| 36 |
+
assert len(state.inventory) == len(case.evidence)
|
| 37 |
+
|
| 38 |
+
def test_advance_to_cross_exam(self, case):
|
| 39 |
+
engine = TurnaboutEngine(case, difficulty="easy")
|
| 40 |
+
state, _ = engine.reset()
|
| 41 |
+
assert state.phase == GamePhase.COURT_TESTIMONY
|
| 42 |
+
|
| 43 |
+
state, obs, _, _, _ = engine.step(Action(ActionType.NEXT_STATEMENT))
|
| 44 |
+
assert state.phase == GamePhase.COURT_CROSS_EXAM
|
| 45 |
+
assert "CROSS-EXAMINATION" in obs
|
| 46 |
+
|
| 47 |
+
def test_press_statement(self, case):
|
| 48 |
+
engine = TurnaboutEngine(case, difficulty="easy")
|
| 49 |
+
state, _ = engine.reset()
|
| 50 |
+
engine.step(Action(ActionType.NEXT_STATEMENT))
|
| 51 |
+
|
| 52 |
+
state, obs, _, _, _ = engine.step(Action(ActionType.PRESS, target_id="patrol_1"))
|
| 53 |
+
assert "HOLD IT" in obs
|
| 54 |
+
assert "patrol_1" in state.statements_pressed
|
| 55 |
+
|
| 56 |
+
def test_correct_contradiction_advances(self, case):
|
| 57 |
+
engine = TurnaboutEngine(case, difficulty="easy")
|
| 58 |
+
engine.reset()
|
| 59 |
+
engine.step(Action(ActionType.NEXT_STATEMENT))
|
| 60 |
+
|
| 61 |
+
state, obs, reward, done, info = engine.step(
|
| 62 |
+
Action(ActionType.PRESENT_EVIDENCE, target_id="patrol_schedule")
|
| 63 |
+
)
|
| 64 |
+
assert "OBJECTION" in obs
|
| 65 |
+
assert reward > 0
|
| 66 |
+
assert "patrol_1" in state.contradictions_found
|
| 67 |
+
|
| 68 |
+
def test_wrong_evidence_gives_penalty(self, case):
|
| 69 |
+
engine = TurnaboutEngine(case, difficulty="easy")
|
| 70 |
+
engine.reset()
|
| 71 |
+
engine.step(Action(ActionType.NEXT_STATEMENT))
|
| 72 |
+
|
| 73 |
+
state, obs, reward, done, _ = engine.step(
|
| 74 |
+
Action(ActionType.PRESENT_EVIDENCE, target_id="incident_report")
|
| 75 |
+
)
|
| 76 |
+
assert state.penalties == 1
|
| 77 |
+
assert reward < 0
|
| 78 |
+
assert "Penalty" in obs
|
| 79 |
+
|
| 80 |
+
def test_too_many_penalties_loses(self, case):
|
| 81 |
+
engine = TurnaboutEngine(case, difficulty="easy")
|
| 82 |
+
engine.reset()
|
| 83 |
+
engine.step(Action(ActionType.NEXT_STATEMENT))
|
| 84 |
+
|
| 85 |
+
for _ in range(5):
|
| 86 |
+
state, obs, _, done, _ = engine.step(
|
| 87 |
+
Action(ActionType.PRESENT_EVIDENCE, target_id="incident_report")
|
| 88 |
+
)
|
| 89 |
+
if done:
|
| 90 |
+
break
|
| 91 |
+
|
| 92 |
+
assert state.phase == GamePhase.VERDICT_LOSE
|
| 93 |
+
assert done
|
| 94 |
+
assert "GUILTY" in obs
|
| 95 |
+
|
| 96 |
+
def test_full_win_path(self, case):
|
| 97 |
+
engine = TurnaboutEngine(case, difficulty="easy")
|
| 98 |
+
engine.reset()
|
| 99 |
+
|
| 100 |
+
# Testimony -> cross-exam
|
| 101 |
+
engine.step(Action(ActionType.NEXT_STATEMENT))
|
| 102 |
+
|
| 103 |
+
# Find first contradiction: patrol_schedule vs patrol_1
|
| 104 |
+
engine.step(Action(ActionType.PRESENT_EVIDENCE, target_id="patrol_schedule"))
|
| 105 |
+
|
| 106 |
+
# New testimony starts, advance to cross-exam
|
| 107 |
+
engine.step(Action(ActionType.NEXT_STATEMENT))
|
| 108 |
+
|
| 109 |
+
# Press revised_2 to reveal revised_2b
|
| 110 |
+
engine.step(Action(ActionType.NEXT_STATEMENT)) # move to stmt index 1 (revised_2)
|
| 111 |
+
state, obs, _, _, _ = engine.step(Action(ActionType.PRESS, target_id="revised_2"))
|
| 112 |
+
assert "revised_2b" in state.revealed_statements or "amend" in obs.lower()
|
| 113 |
+
|
| 114 |
+
# Navigate to revised_2b and present cloning_device
|
| 115 |
+
engine.step(Action(ActionType.NEXT_STATEMENT)) # to revised_2b
|
| 116 |
+
state, obs, _, done, _ = engine.step(
|
| 117 |
+
Action(ActionType.PRESENT_EVIDENCE, target_id="cloning_device")
|
| 118 |
+
)
|
| 119 |
+
assert "OBJECTION" in obs
|
| 120 |
+
assert "revised_2b" in state.contradictions_found
|
| 121 |
+
|
| 122 |
+
# Navigate to revised_3 and present alex_alibi
|
| 123 |
+
engine.step(Action(ActionType.NEXT_STATEMENT)) # to revised_3
|
| 124 |
+
state, obs, reward, done, info = engine.step(
|
| 125 |
+
Action(ActionType.PRESENT_EVIDENCE, target_id="alex_alibi")
|
| 126 |
+
)
|
| 127 |
+
assert "OBJECTION" in obs
|
| 128 |
+
assert done
|
| 129 |
+
assert state.phase == GamePhase.VERDICT_WIN
|
| 130 |
+
assert "NOT GUILTY" in obs
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class TestHardMode:
|
| 134 |
+
def test_starts_in_investigation(self, case):
|
| 135 |
+
engine = TurnaboutEngine(case, difficulty="hard")
|
| 136 |
+
state, obs = engine.reset()
|
| 137 |
+
assert state.phase == GamePhase.INVESTIGATION
|
| 138 |
+
assert "INVESTIGATION" in obs
|
| 139 |
+
|
| 140 |
+
def test_pre_given_evidence_only(self, case):
|
| 141 |
+
engine = TurnaboutEngine(case, difficulty="hard")
|
| 142 |
+
state, _ = engine.reset()
|
| 143 |
+
pre_given = {e.id for e in case.get_pre_given_evidence()}
|
| 144 |
+
assert state.inventory == pre_given
|
| 145 |
+
|
| 146 |
+
def test_cannot_go_to_court_without_evidence(self, case):
|
| 147 |
+
engine = TurnaboutEngine(case, difficulty="hard")
|
| 148 |
+
engine.reset()
|
| 149 |
+
valid = engine.get_valid_actions()
|
| 150 |
+
court_action = Action(ActionType.GO_TO_COURT)
|
| 151 |
+
assert court_action not in valid
|
| 152 |
+
|
| 153 |
+
def test_move_between_locations(self, case):
|
| 154 |
+
engine = TurnaboutEngine(case, difficulty="hard")
|
| 155 |
+
state, _ = engine.reset()
|
| 156 |
+
start = state.current_location_id
|
| 157 |
+
|
| 158 |
+
# Move to a connected location
|
| 159 |
+
valid = engine.get_valid_actions()
|
| 160 |
+
move_actions = [a for a in valid if a.action_type == ActionType.MOVE]
|
| 161 |
+
assert len(move_actions) > 0
|
| 162 |
+
|
| 163 |
+
target = move_actions[0].target_id
|
| 164 |
+
state, obs, _, _, _ = engine.step(move_actions[0])
|
| 165 |
+
assert state.current_location_id == target
|
| 166 |
+
|
| 167 |
+
def test_examine_finds_evidence(self, case):
|
| 168 |
+
engine = TurnaboutEngine(case, difficulty="hard")
|
| 169 |
+
engine.reset()
|
| 170 |
+
|
| 171 |
+
# Navigate to security_office and examine filing_cabinet
|
| 172 |
+
engine.step(Action(ActionType.MOVE, target_id="lab"))
|
| 173 |
+
engine.step(Action(ActionType.MOVE, target_id="security_office"))
|
| 174 |
+
state, obs, reward, _, _ = engine.step(
|
| 175 |
+
Action(ActionType.EXAMINE, target_id="filing_cabinet")
|
| 176 |
+
)
|
| 177 |
+
assert "patrol_schedule" in state.inventory
|
| 178 |
+
assert reward > 0
|
| 179 |
+
|
| 180 |
+
def test_talk_grants_evidence(self, case):
|
| 181 |
+
engine = TurnaboutEngine(case, difficulty="hard")
|
| 182 |
+
state, _ = engine.reset()
|
| 183 |
+
|
| 184 |
+
if state.current_location_id != "detention_center":
|
| 185 |
+
engine.step(Action(ActionType.MOVE, target_id="detention_center"))
|
| 186 |
+
|
| 187 |
+
state, obs, _, _, _ = engine.step(Action(ActionType.TALK, target_id="alex_park"))
|
| 188 |
+
assert "alex_alibi" in state.inventory
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class TestMetrics:
|
| 192 |
+
def test_compute_on_win(self, case):
|
| 193 |
+
engine = TurnaboutEngine(case, difficulty="easy")
|
| 194 |
+
mc = MetricsComputer(engine)
|
| 195 |
+
state, _ = engine.reset()
|
| 196 |
+
|
| 197 |
+
engine.step(Action(ActionType.NEXT_STATEMENT))
|
| 198 |
+
_, _, r, _, _ = engine.step(Action(ActionType.PRESENT_EVIDENCE, target_id="patrol_schedule"))
|
| 199 |
+
mc.accumulate_reward(r)
|
| 200 |
+
|
| 201 |
+
engine.step(Action(ActionType.NEXT_STATEMENT))
|
| 202 |
+
engine.step(Action(ActionType.NEXT_STATEMENT))
|
| 203 |
+
_, _, r, _, _ = engine.step(Action(ActionType.PRESS, target_id="revised_2"))
|
| 204 |
+
mc.accumulate_reward(r)
|
| 205 |
+
|
| 206 |
+
engine.step(Action(ActionType.NEXT_STATEMENT))
|
| 207 |
+
_, _, r, _, _ = engine.step(
|
| 208 |
+
Action(ActionType.PRESENT_EVIDENCE, target_id="cloning_device")
|
| 209 |
+
)
|
| 210 |
+
mc.accumulate_reward(r)
|
| 211 |
+
|
| 212 |
+
engine.step(Action(ActionType.NEXT_STATEMENT))
|
| 213 |
+
_, _, r, done, _ = engine.step(
|
| 214 |
+
Action(ActionType.PRESENT_EVIDENCE, target_id="alex_alibi")
|
| 215 |
+
)
|
| 216 |
+
mc.accumulate_reward(r)
|
| 217 |
+
|
| 218 |
+
metrics = mc.compute(engine.state)
|
| 219 |
+
assert metrics.won
|
| 220 |
+
assert metrics.contradiction_accuracy == 1.0
|
| 221 |
+
assert metrics.wrong_presentations == 0
|
| 222 |
+
assert metrics.contradictions_found == 3
|
tests/test_gym_env.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the Gymnasium environment wrapper."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import gymnasium as gym
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
import turnabout.envs # noqa: F401 - triggers registration
|
| 11 |
+
from turnabout.envs.gym_env import TurnaboutEnv
|
| 12 |
+
|
| 13 |
+
CASES_DIR = Path(__file__).parent.parent / "turnabout" / "cases"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@pytest.fixture
|
| 17 |
+
def case_path():
|
| 18 |
+
p = CASES_DIR / "stolen_prototype.json"
|
| 19 |
+
if not p.exists():
|
| 20 |
+
pytest.skip("stolen_prototype.json not yet created")
|
| 21 |
+
return str(p)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class TestDiscreteMode:
|
| 25 |
+
def test_create(self, case_path):
|
| 26 |
+
env = TurnaboutEnv(case_path=case_path, difficulty="easy", mode="discrete")
|
| 27 |
+
assert isinstance(env.action_space, gym.spaces.Discrete)
|
| 28 |
+
assert isinstance(env.observation_space, gym.spaces.Dict)
|
| 29 |
+
|
| 30 |
+
def test_reset(self, case_path):
|
| 31 |
+
env = TurnaboutEnv(case_path=case_path, difficulty="easy", mode="discrete")
|
| 32 |
+
obs, info = env.reset()
|
| 33 |
+
assert isinstance(obs, dict)
|
| 34 |
+
assert "phase" in obs
|
| 35 |
+
assert "evidence_held" in obs
|
| 36 |
+
assert isinstance(obs["evidence_held"], np.ndarray)
|
| 37 |
+
|
| 38 |
+
def test_action_masks(self, case_path):
|
| 39 |
+
env = TurnaboutEnv(case_path=case_path, difficulty="easy", mode="discrete")
|
| 40 |
+
env.reset()
|
| 41 |
+
mask = env.action_masks()
|
| 42 |
+
assert isinstance(mask, np.ndarray)
|
| 43 |
+
assert mask.shape == (env.action_space.n,)
|
| 44 |
+
assert mask.sum() > 0
|
| 45 |
+
|
| 46 |
+
def test_step_with_valid_masked_action(self, case_path):
|
| 47 |
+
env = TurnaboutEnv(case_path=case_path, difficulty="easy", mode="discrete")
|
| 48 |
+
env.reset()
|
| 49 |
+
mask = env.action_masks()
|
| 50 |
+
valid_idx = np.where(mask == 1)[0]
|
| 51 |
+
assert len(valid_idx) > 0
|
| 52 |
+
|
| 53 |
+
obs, reward, done, truncated, info = env.step(int(valid_idx[0]))
|
| 54 |
+
assert isinstance(obs, dict)
|
| 55 |
+
assert isinstance(reward, float)
|
| 56 |
+
assert isinstance(done, bool)
|
| 57 |
+
assert isinstance(truncated, bool)
|
| 58 |
+
|
| 59 |
+
def test_info_contains_action_mask(self, case_path):
|
| 60 |
+
env = TurnaboutEnv(case_path=case_path, difficulty="easy", mode="discrete")
|
| 61 |
+
_, info = env.reset()
|
| 62 |
+
assert "action_mask" in info
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class TestTextMode:
|
| 66 |
+
def test_create(self, case_path):
|
| 67 |
+
env = TurnaboutEnv(case_path=case_path, difficulty="easy", mode="text")
|
| 68 |
+
assert isinstance(env.action_space, gym.spaces.Text)
|
| 69 |
+
assert isinstance(env.observation_space, gym.spaces.Text)
|
| 70 |
+
|
| 71 |
+
def test_reset(self, case_path):
|
| 72 |
+
env = TurnaboutEnv(case_path=case_path, difficulty="easy", mode="text")
|
| 73 |
+
obs, info = env.reset()
|
| 74 |
+
assert isinstance(obs, str)
|
| 75 |
+
assert len(obs) > 0
|
| 76 |
+
|
| 77 |
+
def test_step(self, case_path):
|
| 78 |
+
env = TurnaboutEnv(case_path=case_path, difficulty="easy", mode="text")
|
| 79 |
+
env.reset()
|
| 80 |
+
obs, reward, done, truncated, info = env.step("next")
|
| 81 |
+
assert isinstance(obs, str)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class TestGymMake:
|
| 85 |
+
def test_make_text(self, case_path):
|
| 86 |
+
env = gym.make("Turnabout-Text-v0", case_path=case_path, difficulty="easy")
|
| 87 |
+
obs, info = env.reset()
|
| 88 |
+
assert isinstance(obs, str)
|
| 89 |
+
env.close()
|
| 90 |
+
|
| 91 |
+
def test_make_discrete(self, case_path):
|
| 92 |
+
env = gym.make("Turnabout-Discrete-v0", case_path=case_path, difficulty="easy")
|
| 93 |
+
obs, info = env.reset()
|
| 94 |
+
assert isinstance(obs, dict)
|
| 95 |
+
env.close()
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class TestRender:
|
| 99 |
+
def test_render_ansi(self, case_path):
|
| 100 |
+
env = TurnaboutEnv(
|
| 101 |
+
case_path=case_path, difficulty="easy", mode="discrete", render_mode="ansi"
|
| 102 |
+
)
|
| 103 |
+
env.reset()
|
| 104 |
+
text = env.render()
|
| 105 |
+
assert isinstance(text, str)
|
| 106 |
+
assert len(text) > 0
|
tests/test_schema.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the case data schema."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from pydantic import ValidationError
|
| 8 |
+
|
| 9 |
+
from turnabout.core.schema import (
|
| 10 |
+
CaseData,
|
| 11 |
+
Character,
|
| 12 |
+
Contradiction,
|
| 13 |
+
CourtProceedings,
|
| 14 |
+
CourtRound,
|
| 15 |
+
DialogueLine,
|
| 16 |
+
DialogueTree,
|
| 17 |
+
EvidenceAcquisition,
|
| 18 |
+
EvidenceItem,
|
| 19 |
+
EvidencePresentResponse,
|
| 20 |
+
Examinable,
|
| 21 |
+
InvestigationGate,
|
| 22 |
+
Location,
|
| 23 |
+
Statement,
|
| 24 |
+
Testimony,
|
| 25 |
+
WinCondition,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
CASES_DIR = Path(__file__).parent.parent / "turnabout" / "cases"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _make_minimal_case(**overrides) -> dict:
|
| 32 |
+
base = {
|
| 33 |
+
"id": "test_001",
|
| 34 |
+
"title": "Test Case",
|
| 35 |
+
"difficulty": "easy",
|
| 36 |
+
"description": "A test case.",
|
| 37 |
+
"characters": [
|
| 38 |
+
{"id": "defendant", "name": "D", "description": "d", "role": "defendant"},
|
| 39 |
+
{"id": "witness1", "name": "W", "description": "w", "role": "witness"},
|
| 40 |
+
],
|
| 41 |
+
"evidence": [
|
| 42 |
+
{
|
| 43 |
+
"id": "ev1",
|
| 44 |
+
"name": "E1",
|
| 45 |
+
"item_type": "document",
|
| 46 |
+
"description": "desc",
|
| 47 |
+
"detail": "detail",
|
| 48 |
+
"source": "pre_given",
|
| 49 |
+
}
|
| 50 |
+
],
|
| 51 |
+
"court": {
|
| 52 |
+
"penalty_limit": 5,
|
| 53 |
+
"rounds": [
|
| 54 |
+
{
|
| 55 |
+
"witness_id": "witness1",
|
| 56 |
+
"order": 0,
|
| 57 |
+
"initial_testimony_id": "t1",
|
| 58 |
+
"testimonies": [
|
| 59 |
+
{
|
| 60 |
+
"id": "t1",
|
| 61 |
+
"title": "Testimony",
|
| 62 |
+
"witness_id": "witness1",
|
| 63 |
+
"preamble": "The witness testifies.",
|
| 64 |
+
"statements": [
|
| 65 |
+
{
|
| 66 |
+
"id": "s1",
|
| 67 |
+
"text": "I saw something.",
|
| 68 |
+
"press_response": "Well...",
|
| 69 |
+
"contradiction": {
|
| 70 |
+
"evidence_id": "ev1",
|
| 71 |
+
"explanation": "This contradicts!",
|
| 72 |
+
"is_primary": True,
|
| 73 |
+
},
|
| 74 |
+
}
|
| 75 |
+
],
|
| 76 |
+
}
|
| 77 |
+
],
|
| 78 |
+
}
|
| 79 |
+
],
|
| 80 |
+
"win_condition": {"required_contradiction_ids": ["s1"]},
|
| 81 |
+
},
|
| 82 |
+
}
|
| 83 |
+
base.update(overrides)
|
| 84 |
+
return base
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class TestMinimalCase:
|
| 88 |
+
def test_parse_minimal(self):
|
| 89 |
+
case = CaseData(**_make_minimal_case())
|
| 90 |
+
assert case.id == "test_001"
|
| 91 |
+
assert case.difficulty == "easy"
|
| 92 |
+
assert len(case.characters) == 2
|
| 93 |
+
assert len(case.evidence) == 1
|
| 94 |
+
assert case.court.penalty_limit == 5
|
| 95 |
+
|
| 96 |
+
def test_get_character(self):
|
| 97 |
+
case = CaseData(**_make_minimal_case())
|
| 98 |
+
assert case.get_character("defendant") is not None
|
| 99 |
+
assert case.get_character("nonexistent") is None
|
| 100 |
+
|
| 101 |
+
def test_get_evidence(self):
|
| 102 |
+
case = CaseData(**_make_minimal_case())
|
| 103 |
+
assert case.get_evidence("ev1") is not None
|
| 104 |
+
assert case.get_evidence("nonexistent") is None
|
| 105 |
+
|
| 106 |
+
def test_get_testimony(self):
|
| 107 |
+
case = CaseData(**_make_minimal_case())
|
| 108 |
+
assert case.get_testimony("t1") is not None
|
| 109 |
+
assert case.get_testimony("nonexistent") is None
|
| 110 |
+
|
| 111 |
+
def test_get_statement(self):
|
| 112 |
+
case = CaseData(**_make_minimal_case())
|
| 113 |
+
s = case.get_statement("s1")
|
| 114 |
+
assert s is not None
|
| 115 |
+
assert s.contradiction is not None
|
| 116 |
+
assert s.contradiction.evidence_id == "ev1"
|
| 117 |
+
|
| 118 |
+
def test_pre_given_evidence(self):
|
| 119 |
+
case = CaseData(**_make_minimal_case())
|
| 120 |
+
pre = case.get_pre_given_evidence()
|
| 121 |
+
assert len(pre) == 1
|
| 122 |
+
assert pre[0].id == "ev1"
|
| 123 |
+
|
| 124 |
+
def test_required_contradictions(self):
|
| 125 |
+
case = CaseData(**_make_minimal_case())
|
| 126 |
+
stmts = case.get_required_contradiction_statements()
|
| 127 |
+
assert len(stmts) == 1
|
| 128 |
+
assert stmts[0].id == "s1"
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class TestValidation:
|
| 132 |
+
def test_invalid_difficulty(self):
|
| 133 |
+
with pytest.raises(ValidationError):
|
| 134 |
+
CaseData(**_make_minimal_case(difficulty="medium"))
|
| 135 |
+
|
| 136 |
+
def test_investigation_evidence_requires_acquisition(self):
|
| 137 |
+
data = _make_minimal_case()
|
| 138 |
+
data["evidence"].append(
|
| 139 |
+
{
|
| 140 |
+
"id": "ev2",
|
| 141 |
+
"name": "E2",
|
| 142 |
+
"item_type": "physical",
|
| 143 |
+
"description": "d",
|
| 144 |
+
"detail": "d",
|
| 145 |
+
"source": "investigation",
|
| 146 |
+
"acquisition": None,
|
| 147 |
+
}
|
| 148 |
+
)
|
| 149 |
+
with pytest.raises(ValidationError, match="investigation evidence must have acquisition"):
|
| 150 |
+
CaseData(**data)
|
| 151 |
+
|
| 152 |
+
def test_investigation_evidence_with_acquisition_ok(self):
|
| 153 |
+
data = _make_minimal_case()
|
| 154 |
+
data["locations"] = [
|
| 155 |
+
{
|
| 156 |
+
"id": "loc1",
|
| 157 |
+
"name": "L1",
|
| 158 |
+
"description": "d",
|
| 159 |
+
"examinables": [{"id": "obj1", "name": "O1", "description": "d"}],
|
| 160 |
+
}
|
| 161 |
+
]
|
| 162 |
+
data["evidence"].append(
|
| 163 |
+
{
|
| 164 |
+
"id": "ev2",
|
| 165 |
+
"name": "E2",
|
| 166 |
+
"item_type": "physical",
|
| 167 |
+
"description": "d",
|
| 168 |
+
"detail": "d",
|
| 169 |
+
"source": "investigation",
|
| 170 |
+
"acquisition": {
|
| 171 |
+
"method": "examine",
|
| 172 |
+
"location_id": "loc1",
|
| 173 |
+
"target_id": "obj1",
|
| 174 |
+
},
|
| 175 |
+
}
|
| 176 |
+
)
|
| 177 |
+
case = CaseData(**data)
|
| 178 |
+
assert len(case.evidence) == 2
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class TestStolenPrototype:
|
| 182 |
+
@pytest.fixture
|
| 183 |
+
def case(self):
|
| 184 |
+
path = CASES_DIR / "stolen_prototype.json"
|
| 185 |
+
if not path.exists():
|
| 186 |
+
pytest.skip("stolen_prototype.json not yet created")
|
| 187 |
+
with open(path) as f:
|
| 188 |
+
data = json.load(f)
|
| 189 |
+
return CaseData(**data)
|
| 190 |
+
|
| 191 |
+
def test_loads(self, case):
|
| 192 |
+
assert case.id == "stolen_prototype"
|
| 193 |
+
assert case.title == "The Stolen Prototype"
|
| 194 |
+
|
| 195 |
+
def test_characters(self, case):
|
| 196 |
+
assert len(case.characters) == 5
|
| 197 |
+
roles = {c.role for c in case.characters}
|
| 198 |
+
assert "defendant" in roles
|
| 199 |
+
assert "witness" in roles
|
| 200 |
+
|
| 201 |
+
def test_evidence_count(self, case):
|
| 202 |
+
assert len(case.evidence) == 7
|
| 203 |
+
pre_given = case.get_pre_given_evidence()
|
| 204 |
+
assert len(pre_given) == 2
|
| 205 |
+
investigation = [e for e in case.evidence if e.source == "investigation"]
|
| 206 |
+
assert len(investigation) == 5
|
| 207 |
+
|
| 208 |
+
def test_locations(self, case):
|
| 209 |
+
assert len(case.locations) == 5
|
| 210 |
+
hale_office = case.get_location("hale_office")
|
| 211 |
+
assert hale_office is not None
|
| 212 |
+
assert not hale_office.available_from_start
|
| 213 |
+
assert "hale_suspicion" in hale_office.unlock_prerequisites
|
| 214 |
+
|
| 215 |
+
def test_court_structure(self, case):
|
| 216 |
+
assert case.court.penalty_limit == 5
|
| 217 |
+
assert len(case.court.rounds) == 1
|
| 218 |
+
rnd = case.court.rounds[0]
|
| 219 |
+
assert rnd.witness_id == "morrison"
|
| 220 |
+
assert len(rnd.testimonies) == 2
|
| 221 |
+
|
| 222 |
+
def test_win_condition(self, case):
|
| 223 |
+
wc = case.court.win_condition
|
| 224 |
+
assert len(wc.required_contradiction_ids) == 3
|
| 225 |
+
assert "patrol_1" in wc.required_contradiction_ids
|
| 226 |
+
assert "revised_2b" in wc.required_contradiction_ids
|
| 227 |
+
assert "revised_3" in wc.required_contradiction_ids
|
| 228 |
+
|
| 229 |
+
def test_investigation_gate(self, case):
|
| 230 |
+
gate = case.investigation_gate
|
| 231 |
+
assert gate is not None
|
| 232 |
+
assert len(gate.required_evidence) == 4
|
| 233 |
+
assert "cloning_device" in gate.required_evidence
|
| 234 |
+
|
| 235 |
+
def test_all_contradictions_reference_valid_evidence(self, case):
|
| 236 |
+
ev_ids = case.get_all_evidence_ids()
|
| 237 |
+
for rnd in case.court.rounds:
|
| 238 |
+
for t in rnd.testimonies:
|
| 239 |
+
for s in t.statements:
|
| 240 |
+
if s.contradiction:
|
| 241 |
+
assert s.contradiction.evidence_id in ev_ids, (
|
| 242 |
+
f"Statement {s.id} contradiction references unknown evidence "
|
| 243 |
+
f"{s.contradiction.evidence_id}"
|
| 244 |
+
)
|
tests/test_text_env.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the text environment interface."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from turnabout.envs.text_env import TextCourtEnv
|
| 9 |
+
|
| 10 |
+
CASES_DIR = Path(__file__).parent.parent / "turnabout" / "cases"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@pytest.fixture
|
| 14 |
+
def case_path():
|
| 15 |
+
p = CASES_DIR / "stolen_prototype.json"
|
| 16 |
+
if not p.exists():
|
| 17 |
+
pytest.skip("stolen_prototype.json not yet created")
|
| 18 |
+
return str(p)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class TestTextEnvEasy:
|
| 22 |
+
def test_reset(self, case_path):
|
| 23 |
+
env = TextCourtEnv(case_path=case_path, difficulty="easy")
|
| 24 |
+
obs = env.reset()
|
| 25 |
+
assert "COURT" in obs or "TESTIMONY" in obs
|
| 26 |
+
|
| 27 |
+
def test_system_prompt(self, case_path):
|
| 28 |
+
env = TextCourtEnv(case_path=case_path, difficulty="easy")
|
| 29 |
+
prompt = env.system_prompt
|
| 30 |
+
assert "defense attorney" in prompt
|
| 31 |
+
assert "press" in prompt
|
| 32 |
+
|
| 33 |
+
def test_step_returns_4_tuple(self, case_path):
|
| 34 |
+
env = TextCourtEnv(case_path=case_path, difficulty="easy")
|
| 35 |
+
env.reset()
|
| 36 |
+
obs, reward, done, info = env.step("next")
|
| 37 |
+
assert isinstance(obs, str)
|
| 38 |
+
assert isinstance(reward, float)
|
| 39 |
+
assert isinstance(done, bool)
|
| 40 |
+
assert isinstance(info, dict)
|
| 41 |
+
assert "valid_actions" in info
|
| 42 |
+
|
| 43 |
+
def test_invalid_action(self, case_path):
|
| 44 |
+
env = TextCourtEnv(case_path=case_path, difficulty="easy")
|
| 45 |
+
env.reset()
|
| 46 |
+
obs, reward, done, info = env.step("fly to the moon")
|
| 47 |
+
assert "parse" in info.get("parse_error", "").lower() or "parse_error" in info
|
| 48 |
+
assert reward < 0
|
| 49 |
+
|
| 50 |
+
def test_full_easy_win(self, case_path):
|
| 51 |
+
env = TextCourtEnv(case_path=case_path, difficulty="easy")
|
| 52 |
+
env.reset()
|
| 53 |
+
|
| 54 |
+
actions = [
|
| 55 |
+
"next",
|
| 56 |
+
"present patrol_schedule",
|
| 57 |
+
"next",
|
| 58 |
+
"next",
|
| 59 |
+
"press revised_2",
|
| 60 |
+
"next",
|
| 61 |
+
"present cloning_device",
|
| 62 |
+
"next",
|
| 63 |
+
"present alex_alibi",
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
done = False
|
| 67 |
+
for action in actions:
|
| 68 |
+
obs, reward, done, info = env.step(action)
|
| 69 |
+
if done:
|
| 70 |
+
break
|
| 71 |
+
|
| 72 |
+
assert done
|
| 73 |
+
assert "NOT GUILTY" in obs
|
| 74 |
+
|
| 75 |
+
metrics = env.get_metrics()
|
| 76 |
+
assert metrics.won
|
| 77 |
+
assert metrics.contradiction_accuracy == 1.0
|
| 78 |
+
|
| 79 |
+
def test_max_steps(self, case_path):
|
| 80 |
+
env = TextCourtEnv(case_path=case_path, difficulty="easy", max_steps=3)
|
| 81 |
+
env.reset()
|
| 82 |
+
for _ in range(5):
|
| 83 |
+
obs, reward, done, info = env.step("next")
|
| 84 |
+
if done:
|
| 85 |
+
break
|
| 86 |
+
assert done
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class TestTextEnvHard:
|
| 90 |
+
def test_reset_investigation(self, case_path):
|
| 91 |
+
env = TextCourtEnv(case_path=case_path, difficulty="hard")
|
| 92 |
+
obs = env.reset()
|
| 93 |
+
assert "INVESTIGATION" in obs
|
| 94 |
+
|
| 95 |
+
def test_investigate_then_court(self, case_path):
|
| 96 |
+
env = TextCourtEnv(case_path=case_path, difficulty="hard")
|
| 97 |
+
env.reset()
|
| 98 |
+
|
| 99 |
+
investigation_actions = [
|
| 100 |
+
"talk alex_park",
|
| 101 |
+
"move lab",
|
| 102 |
+
"move security_office",
|
| 103 |
+
"examine filing_cabinet",
|
| 104 |
+
"examine monitor_desk",
|
| 105 |
+
"move lab",
|
| 106 |
+
"present security_footage to chen",
|
| 107 |
+
"move main_hallway",
|
| 108 |
+
"move hale_office",
|
| 109 |
+
"examine hale_desk",
|
| 110 |
+
"go to court",
|
| 111 |
+
]
|
| 112 |
+
|
| 113 |
+
for action in investigation_actions:
|
| 114 |
+
obs, reward, done, info = env.step(action)
|
| 115 |
+
assert not done, f"Game ended unexpectedly after '{action}': {obs}"
|
| 116 |
+
|
| 117 |
+
assert "COURT" in obs
|
| 118 |
+
|
| 119 |
+
court_actions = [
|
| 120 |
+
"next",
|
| 121 |
+
"present patrol_schedule",
|
| 122 |
+
"next",
|
| 123 |
+
"next",
|
| 124 |
+
"press revised_2",
|
| 125 |
+
"next",
|
| 126 |
+
"present cloning_device",
|
| 127 |
+
"next",
|
| 128 |
+
"present alex_alibi",
|
| 129 |
+
]
|
| 130 |
+
|
| 131 |
+
for action in court_actions:
|
| 132 |
+
obs, reward, done, info = env.step(action)
|
| 133 |
+
if done:
|
| 134 |
+
break
|
| 135 |
+
|
| 136 |
+
assert done
|
| 137 |
+
assert "NOT GUILTY" in obs
|
tests/test_validator.py
ADDED
|
@@ -0,0 +1,863 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the case data validator."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from turnabout.core.schema import CaseData
|
| 9 |
+
from turnabout.generation.validator import CaseValidator, ValidationReport
|
| 10 |
+
|
| 11 |
+
CASES_DIR = Path(__file__).parent.parent / "turnabout" / "cases"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# ------------------------------------------------------------------
|
| 15 |
+
# Fixtures
|
| 16 |
+
# ------------------------------------------------------------------
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.fixture
|
| 20 |
+
def validator():
|
| 21 |
+
return CaseValidator()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@pytest.fixture
|
| 25 |
+
def stolen_prototype():
|
| 26 |
+
path = CASES_DIR / "stolen_prototype.json"
|
| 27 |
+
if not path.exists():
|
| 28 |
+
pytest.skip("stolen_prototype.json not found")
|
| 29 |
+
with open(path) as f:
|
| 30 |
+
data = json.load(f)
|
| 31 |
+
return CaseData(**data)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _make_minimal_case(**overrides) -> dict:
|
| 35 |
+
"""Build a minimal valid case dict for mutation-based tests."""
|
| 36 |
+
base = {
|
| 37 |
+
"id": "test_case",
|
| 38 |
+
"title": "Test Case",
|
| 39 |
+
"difficulty": "easy",
|
| 40 |
+
"description": "A test case.",
|
| 41 |
+
"characters": [
|
| 42 |
+
{"id": "defendant", "name": "D", "description": "d", "role": "defendant"},
|
| 43 |
+
{"id": "witness1", "name": "W", "description": "w", "role": "witness"},
|
| 44 |
+
],
|
| 45 |
+
"evidence": [
|
| 46 |
+
{
|
| 47 |
+
"id": "ev1",
|
| 48 |
+
"name": "E1",
|
| 49 |
+
"item_type": "document",
|
| 50 |
+
"description": "desc",
|
| 51 |
+
"detail": "detail",
|
| 52 |
+
"source": "pre_given",
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"id": "ev2",
|
| 56 |
+
"name": "E2",
|
| 57 |
+
"item_type": "document",
|
| 58 |
+
"description": "desc",
|
| 59 |
+
"detail": "detail",
|
| 60 |
+
"source": "pre_given",
|
| 61 |
+
},
|
| 62 |
+
],
|
| 63 |
+
"court": {
|
| 64 |
+
"penalty_limit": 5,
|
| 65 |
+
"rounds": [
|
| 66 |
+
{
|
| 67 |
+
"witness_id": "witness1",
|
| 68 |
+
"order": 0,
|
| 69 |
+
"initial_testimony_id": "t1",
|
| 70 |
+
"testimonies": [
|
| 71 |
+
{
|
| 72 |
+
"id": "t1",
|
| 73 |
+
"title": "Testimony",
|
| 74 |
+
"witness_id": "witness1",
|
| 75 |
+
"preamble": "The witness testifies.",
|
| 76 |
+
"statements": [
|
| 77 |
+
{
|
| 78 |
+
"id": "s1",
|
| 79 |
+
"text": "I saw something.",
|
| 80 |
+
"press_response": "Well...",
|
| 81 |
+
"contradiction": {
|
| 82 |
+
"evidence_id": "ev1",
|
| 83 |
+
"explanation": "This contradicts!",
|
| 84 |
+
"is_primary": True,
|
| 85 |
+
},
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"id": "s2",
|
| 89 |
+
"text": "I was there.",
|
| 90 |
+
"press_response": "Yes...",
|
| 91 |
+
"contradiction": None,
|
| 92 |
+
},
|
| 93 |
+
],
|
| 94 |
+
}
|
| 95 |
+
],
|
| 96 |
+
}
|
| 97 |
+
],
|
| 98 |
+
"win_condition": {"required_contradiction_ids": ["s1"]},
|
| 99 |
+
},
|
| 100 |
+
}
|
| 101 |
+
base.update(overrides)
|
| 102 |
+
return base
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _make_minimal_case_data(**overrides) -> CaseData:
|
| 106 |
+
return CaseData(**_make_minimal_case(**overrides))
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ------------------------------------------------------------------
|
| 110 |
+
# Tests: ValidationReport
|
| 111 |
+
# ------------------------------------------------------------------
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class TestValidationReport:
|
| 115 |
+
def test_default_is_valid(self):
|
| 116 |
+
report = ValidationReport()
|
| 117 |
+
assert report.is_valid is True
|
| 118 |
+
assert report.errors == []
|
| 119 |
+
assert report.warnings == []
|
| 120 |
+
|
| 121 |
+
def test_add_error_makes_invalid(self):
|
| 122 |
+
report = ValidationReport()
|
| 123 |
+
report.add_error("something broke")
|
| 124 |
+
assert report.is_valid is False
|
| 125 |
+
assert len(report.errors) == 1
|
| 126 |
+
|
| 127 |
+
def test_add_warning_stays_valid(self):
|
| 128 |
+
report = ValidationReport()
|
| 129 |
+
report.add_warning("minor issue")
|
| 130 |
+
assert report.is_valid is True
|
| 131 |
+
assert len(report.warnings) == 1
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ------------------------------------------------------------------
|
| 135 |
+
# Tests: Valid cases
|
| 136 |
+
# ------------------------------------------------------------------
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class TestValidCases:
|
| 140 |
+
def test_stolen_prototype_is_valid(self, validator, stolen_prototype):
|
| 141 |
+
report = validator.validate(stolen_prototype)
|
| 142 |
+
assert report.is_valid, f"Errors: {report.errors}"
|
| 143 |
+
|
| 144 |
+
def test_minimal_case_is_valid(self, validator):
|
| 145 |
+
case = _make_minimal_case_data()
|
| 146 |
+
report = validator.validate(case)
|
| 147 |
+
assert report.is_valid, f"Errors: {report.errors}"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# ------------------------------------------------------------------
|
| 151 |
+
# Tests: Reference validation
|
| 152 |
+
# ------------------------------------------------------------------
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class TestReferenceValidation:
|
| 156 |
+
def test_bad_location_connection(self, validator):
|
| 157 |
+
data = _make_minimal_case()
|
| 158 |
+
data["locations"] = [
|
| 159 |
+
{
|
| 160 |
+
"id": "loc1",
|
| 161 |
+
"name": "L1",
|
| 162 |
+
"description": "d",
|
| 163 |
+
"connected_to": ["nonexistent_loc"],
|
| 164 |
+
"available_from_start": True,
|
| 165 |
+
}
|
| 166 |
+
]
|
| 167 |
+
case = CaseData(**data)
|
| 168 |
+
report = validator.validate(case)
|
| 169 |
+
assert not report.is_valid
|
| 170 |
+
assert any("nonexistent_loc" in e for e in report.errors)
|
| 171 |
+
|
| 172 |
+
def test_bad_character_in_location(self, validator):
|
| 173 |
+
data = _make_minimal_case()
|
| 174 |
+
data["locations"] = [
|
| 175 |
+
{
|
| 176 |
+
"id": "loc1",
|
| 177 |
+
"name": "L1",
|
| 178 |
+
"description": "d",
|
| 179 |
+
"characters_present": ["ghost_character"],
|
| 180 |
+
"available_from_start": True,
|
| 181 |
+
}
|
| 182 |
+
]
|
| 183 |
+
case = CaseData(**data)
|
| 184 |
+
report = validator.validate(case)
|
| 185 |
+
assert not report.is_valid
|
| 186 |
+
assert any("ghost_character" in e for e in report.errors)
|
| 187 |
+
|
| 188 |
+
def test_bad_evidence_acquisition_location(self, validator):
|
| 189 |
+
data = _make_minimal_case()
|
| 190 |
+
data["locations"] = [
|
| 191 |
+
{
|
| 192 |
+
"id": "loc1",
|
| 193 |
+
"name": "L1",
|
| 194 |
+
"description": "d",
|
| 195 |
+
"examinables": [
|
| 196 |
+
{"id": "obj1", "name": "O1", "description": "d"}
|
| 197 |
+
],
|
| 198 |
+
"available_from_start": True,
|
| 199 |
+
}
|
| 200 |
+
]
|
| 201 |
+
data["evidence"].append(
|
| 202 |
+
{
|
| 203 |
+
"id": "ev3",
|
| 204 |
+
"name": "E3",
|
| 205 |
+
"item_type": "physical",
|
| 206 |
+
"description": "d",
|
| 207 |
+
"detail": "d",
|
| 208 |
+
"source": "investigation",
|
| 209 |
+
"acquisition": {
|
| 210 |
+
"method": "examine",
|
| 211 |
+
"location_id": "bad_loc",
|
| 212 |
+
"target_id": "obj1",
|
| 213 |
+
},
|
| 214 |
+
}
|
| 215 |
+
)
|
| 216 |
+
case = CaseData(**data)
|
| 217 |
+
report = validator.validate(case)
|
| 218 |
+
assert not report.is_valid
|
| 219 |
+
assert any("bad_loc" in e for e in report.errors)
|
| 220 |
+
|
| 221 |
+
def test_bad_evidence_acquisition_target(self, validator):
|
| 222 |
+
data = _make_minimal_case()
|
| 223 |
+
data["locations"] = [
|
| 224 |
+
{
|
| 225 |
+
"id": "loc1",
|
| 226 |
+
"name": "L1",
|
| 227 |
+
"description": "d",
|
| 228 |
+
"examinables": [],
|
| 229 |
+
"available_from_start": True,
|
| 230 |
+
}
|
| 231 |
+
]
|
| 232 |
+
data["evidence"].append(
|
| 233 |
+
{
|
| 234 |
+
"id": "ev3",
|
| 235 |
+
"name": "E3",
|
| 236 |
+
"item_type": "physical",
|
| 237 |
+
"description": "d",
|
| 238 |
+
"detail": "d",
|
| 239 |
+
"source": "investigation",
|
| 240 |
+
"acquisition": {
|
| 241 |
+
"method": "examine",
|
| 242 |
+
"location_id": "loc1",
|
| 243 |
+
"target_id": "bad_target",
|
| 244 |
+
},
|
| 245 |
+
}
|
| 246 |
+
)
|
| 247 |
+
case = CaseData(**data)
|
| 248 |
+
report = validator.validate(case)
|
| 249 |
+
assert not report.is_valid
|
| 250 |
+
assert any("bad_target" in e for e in report.errors)
|
| 251 |
+
|
| 252 |
+
def test_bad_win_condition_statement(self, validator):
|
| 253 |
+
data = _make_minimal_case()
|
| 254 |
+
data["court"]["win_condition"]["required_contradiction_ids"] = [
|
| 255 |
+
"nonexistent_statement"
|
| 256 |
+
]
|
| 257 |
+
case = CaseData(**data)
|
| 258 |
+
report = validator.validate(case)
|
| 259 |
+
assert not report.is_valid
|
| 260 |
+
assert any("nonexistent_statement" in e for e in report.errors)
|
| 261 |
+
|
| 262 |
+
def test_bad_dialogue_character(self, validator):
|
| 263 |
+
data = _make_minimal_case()
|
| 264 |
+
data["dialogues"] = [
|
| 265 |
+
{
|
| 266 |
+
"character_id": "nobody",
|
| 267 |
+
"lines": [],
|
| 268 |
+
"present_responses": [],
|
| 269 |
+
}
|
| 270 |
+
]
|
| 271 |
+
case = CaseData(**data)
|
| 272 |
+
report = validator.validate(case)
|
| 273 |
+
assert not report.is_valid
|
| 274 |
+
assert any("nobody" in e for e in report.errors)
|
| 275 |
+
|
| 276 |
+
def test_bad_court_round_witness(self, validator):
|
| 277 |
+
data = _make_minimal_case()
|
| 278 |
+
data["court"]["rounds"][0]["witness_id"] = "missing_witness"
|
| 279 |
+
case = CaseData(**data)
|
| 280 |
+
report = validator.validate(case)
|
| 281 |
+
assert not report.is_valid
|
| 282 |
+
assert any("missing_witness" in e for e in report.errors)
|
| 283 |
+
|
| 284 |
+
def test_bad_triggers_testimony(self, validator):
|
| 285 |
+
data = _make_minimal_case()
|
| 286 |
+
stmt = data["court"]["rounds"][0]["testimonies"][0]["statements"][0]
|
| 287 |
+
stmt["contradiction"]["triggers_testimony"] = "phantom_testimony"
|
| 288 |
+
case = CaseData(**data)
|
| 289 |
+
report = validator.validate(case)
|
| 290 |
+
assert not report.is_valid
|
| 291 |
+
assert any("phantom_testimony" in e for e in report.errors)
|
| 292 |
+
|
| 293 |
+
def test_bad_press_reveals_statement(self, validator):
|
| 294 |
+
data = _make_minimal_case()
|
| 295 |
+
stmt = data["court"]["rounds"][0]["testimonies"][0]["statements"][0]
|
| 296 |
+
stmt["press_reveals_statement"] = "ghost_statement"
|
| 297 |
+
case = CaseData(**data)
|
| 298 |
+
report = validator.validate(case)
|
| 299 |
+
assert not report.is_valid
|
| 300 |
+
assert any("ghost_statement" in e for e in report.errors)
|
| 301 |
+
|
| 302 |
+
def test_bad_final_evidence_id(self, validator):
|
| 303 |
+
data = _make_minimal_case()
|
| 304 |
+
data["court"]["win_condition"]["final_evidence_id"] = "missing_ev"
|
| 305 |
+
case = CaseData(**data)
|
| 306 |
+
report = validator.validate(case)
|
| 307 |
+
assert not report.is_valid
|
| 308 |
+
assert any("missing_ev" in e for e in report.errors)
|
| 309 |
+
|
| 310 |
+
def test_bad_investigation_gate_evidence(self, validator):
|
| 311 |
+
data = _make_minimal_case()
|
| 312 |
+
data["investigation_gate"] = {
|
| 313 |
+
"required_evidence": ["nonexistent_ev"],
|
| 314 |
+
"auto_advance": False,
|
| 315 |
+
}
|
| 316 |
+
case = CaseData(**data)
|
| 317 |
+
report = validator.validate(case)
|
| 318 |
+
assert not report.is_valid
|
| 319 |
+
assert any("nonexistent_ev" in e for e in report.errors)
|
| 320 |
+
|
| 321 |
+
def test_bad_location_unlock_prerequisite(self, validator):
|
| 322 |
+
data = _make_minimal_case()
|
| 323 |
+
data["locations"] = [
|
| 324 |
+
{
|
| 325 |
+
"id": "loc1",
|
| 326 |
+
"name": "L1",
|
| 327 |
+
"description": "d",
|
| 328 |
+
"available_from_start": True,
|
| 329 |
+
"connected_to": ["loc2"],
|
| 330 |
+
},
|
| 331 |
+
{
|
| 332 |
+
"id": "loc2",
|
| 333 |
+
"name": "L2",
|
| 334 |
+
"description": "d",
|
| 335 |
+
"available_from_start": False,
|
| 336 |
+
"unlock_prerequisites": ["fake_ev"],
|
| 337 |
+
"connected_to": ["loc1"],
|
| 338 |
+
},
|
| 339 |
+
]
|
| 340 |
+
case = CaseData(**data)
|
| 341 |
+
report = validator.validate(case)
|
| 342 |
+
assert not report.is_valid
|
| 343 |
+
assert any("fake_ev" in e for e in report.errors)
|
| 344 |
+
|
| 345 |
+
def test_bad_dialogue_line_grants_evidence(self, validator):
|
| 346 |
+
data = _make_minimal_case()
|
| 347 |
+
data["dialogues"] = [
|
| 348 |
+
{
|
| 349 |
+
"character_id": "witness1",
|
| 350 |
+
"lines": [
|
| 351 |
+
{
|
| 352 |
+
"id": "line1",
|
| 353 |
+
"text": "hi",
|
| 354 |
+
"grants_evidence": "missing_ev",
|
| 355 |
+
}
|
| 356 |
+
],
|
| 357 |
+
"present_responses": [],
|
| 358 |
+
}
|
| 359 |
+
]
|
| 360 |
+
case = CaseData(**data)
|
| 361 |
+
report = validator.validate(case)
|
| 362 |
+
assert not report.is_valid
|
| 363 |
+
assert any("missing_ev" in e for e in report.errors)
|
| 364 |
+
|
| 365 |
+
def test_bad_present_response_evidence(self, validator):
|
| 366 |
+
data = _make_minimal_case()
|
| 367 |
+
data["dialogues"] = [
|
| 368 |
+
{
|
| 369 |
+
"character_id": "witness1",
|
| 370 |
+
"lines": [],
|
| 371 |
+
"present_responses": [
|
| 372 |
+
{
|
| 373 |
+
"evidence_id": "ghost_ev",
|
| 374 |
+
"text": "hmm",
|
| 375 |
+
}
|
| 376 |
+
],
|
| 377 |
+
}
|
| 378 |
+
]
|
| 379 |
+
case = CaseData(**data)
|
| 380 |
+
report = validator.validate(case)
|
| 381 |
+
assert not report.is_valid
|
| 382 |
+
assert any("ghost_ev" in e for e in report.errors)
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
# ------------------------------------------------------------------
|
| 386 |
+
# Tests: Contradiction validation
|
| 387 |
+
# ------------------------------------------------------------------
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
class TestContradictionValidation:
|
| 391 |
+
def test_bad_contradiction_evidence(self, validator):
|
| 392 |
+
data = _make_minimal_case()
|
| 393 |
+
stmt = data["court"]["rounds"][0]["testimonies"][0]["statements"][0]
|
| 394 |
+
stmt["contradiction"]["evidence_id"] = "nonexistent_ev"
|
| 395 |
+
case = CaseData(**data)
|
| 396 |
+
report = validator.validate(case)
|
| 397 |
+
assert not report.is_valid
|
| 398 |
+
assert any("nonexistent_ev" in e for e in report.errors)
|
| 399 |
+
|
| 400 |
+
def test_valid_contradiction_evidence(self, validator):
|
| 401 |
+
case = _make_minimal_case_data()
|
| 402 |
+
errors = validator._validate_contradictions(case)
|
| 403 |
+
assert errors == []
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
# ------------------------------------------------------------------
|
| 407 |
+
# Tests: Solvability validation
|
| 408 |
+
# ------------------------------------------------------------------
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
class TestSolvabilityValidation:
|
| 412 |
+
def test_win_condition_statement_without_contradiction(self, validator):
|
| 413 |
+
data = _make_minimal_case()
|
| 414 |
+
# s2 has no contradiction but we require it in win condition
|
| 415 |
+
data["court"]["win_condition"]["required_contradiction_ids"] = ["s2"]
|
| 416 |
+
case = CaseData(**data)
|
| 417 |
+
report = validator.validate(case)
|
| 418 |
+
assert not report.is_valid
|
| 419 |
+
assert any("no contradiction defined" in e for e in report.errors)
|
| 420 |
+
|
| 421 |
+
def test_unreachable_testimony_for_win(self, validator):
|
| 422 |
+
data = _make_minimal_case()
|
| 423 |
+
# Add a second testimony that is never triggered
|
| 424 |
+
data["court"]["rounds"][0]["testimonies"].append(
|
| 425 |
+
{
|
| 426 |
+
"id": "t2",
|
| 427 |
+
"title": "Secret Testimony",
|
| 428 |
+
"witness_id": "witness1",
|
| 429 |
+
"preamble": "Hidden.",
|
| 430 |
+
"statements": [
|
| 431 |
+
{
|
| 432 |
+
"id": "s3",
|
| 433 |
+
"text": "Secret statement.",
|
| 434 |
+
"press_response": "...",
|
| 435 |
+
"contradiction": {
|
| 436 |
+
"evidence_id": "ev2",
|
| 437 |
+
"explanation": "Objection!",
|
| 438 |
+
"is_primary": True,
|
| 439 |
+
},
|
| 440 |
+
}
|
| 441 |
+
],
|
| 442 |
+
}
|
| 443 |
+
)
|
| 444 |
+
# Require s3 which is in unreachable t2
|
| 445 |
+
data["court"]["win_condition"]["required_contradiction_ids"] = ["s1", "s3"]
|
| 446 |
+
case = CaseData(**data)
|
| 447 |
+
report = validator.validate(case)
|
| 448 |
+
assert not report.is_valid
|
| 449 |
+
assert any("unreachable" in e.lower() for e in report.errors)
|
| 450 |
+
|
| 451 |
+
def test_reachable_testimony_via_trigger(self, validator):
|
| 452 |
+
"""Testimony reachable through triggers_testimony chain should pass."""
|
| 453 |
+
data = _make_minimal_case()
|
| 454 |
+
# s1 contradiction triggers t2
|
| 455 |
+
stmt = data["court"]["rounds"][0]["testimonies"][0]["statements"][0]
|
| 456 |
+
stmt["contradiction"]["triggers_testimony"] = "t2"
|
| 457 |
+
data["court"]["rounds"][0]["testimonies"].append(
|
| 458 |
+
{
|
| 459 |
+
"id": "t2",
|
| 460 |
+
"title": "Second Testimony",
|
| 461 |
+
"witness_id": "witness1",
|
| 462 |
+
"preamble": "More.",
|
| 463 |
+
"statements": [
|
| 464 |
+
{
|
| 465 |
+
"id": "s3",
|
| 466 |
+
"text": "Second statement.",
|
| 467 |
+
"press_response": "...",
|
| 468 |
+
"contradiction": {
|
| 469 |
+
"evidence_id": "ev2",
|
| 470 |
+
"explanation": "Objection!",
|
| 471 |
+
"is_primary": True,
|
| 472 |
+
},
|
| 473 |
+
}
|
| 474 |
+
],
|
| 475 |
+
}
|
| 476 |
+
)
|
| 477 |
+
data["court"]["win_condition"]["required_contradiction_ids"] = ["s1", "s3"]
|
| 478 |
+
case = CaseData(**data)
|
| 479 |
+
report = validator.validate(case)
|
| 480 |
+
assert report.is_valid, f"Errors: {report.errors}"
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
# ------------------------------------------------------------------
|
| 484 |
+
# Tests: Reachability validation
|
| 485 |
+
# ------------------------------------------------------------------
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
class TestReachabilityValidation:
|
| 489 |
+
def test_unreachable_investigation_evidence(self, validator):
|
| 490 |
+
data = _make_minimal_case()
|
| 491 |
+
data["locations"] = [
|
| 492 |
+
{
|
| 493 |
+
"id": "loc1",
|
| 494 |
+
"name": "L1",
|
| 495 |
+
"description": "d",
|
| 496 |
+
"examinables": [
|
| 497 |
+
{"id": "obj1", "name": "O1", "description": "d"}
|
| 498 |
+
],
|
| 499 |
+
"available_from_start": True,
|
| 500 |
+
"connected_to": ["loc2"],
|
| 501 |
+
},
|
| 502 |
+
{
|
| 503 |
+
"id": "loc2",
|
| 504 |
+
"name": "L2",
|
| 505 |
+
"description": "d",
|
| 506 |
+
"examinables": [
|
| 507 |
+
{"id": "obj2", "name": "O2", "description": "d"}
|
| 508 |
+
],
|
| 509 |
+
"available_from_start": False,
|
| 510 |
+
# Requires ev3 to unlock, but ev3 is IN loc2 -- catch-22
|
| 511 |
+
"unlock_prerequisites": ["ev3"],
|
| 512 |
+
"connected_to": ["loc1"],
|
| 513 |
+
},
|
| 514 |
+
]
|
| 515 |
+
data["evidence"].append(
|
| 516 |
+
{
|
| 517 |
+
"id": "ev3",
|
| 518 |
+
"name": "E3",
|
| 519 |
+
"item_type": "physical",
|
| 520 |
+
"description": "d",
|
| 521 |
+
"detail": "d",
|
| 522 |
+
"source": "investigation",
|
| 523 |
+
"acquisition": {
|
| 524 |
+
"method": "examine",
|
| 525 |
+
"location_id": "loc2",
|
| 526 |
+
"target_id": "obj2",
|
| 527 |
+
},
|
| 528 |
+
}
|
| 529 |
+
)
|
| 530 |
+
case = CaseData(**data)
|
| 531 |
+
report = validator.validate(case)
|
| 532 |
+
assert not report.is_valid
|
| 533 |
+
assert any("unreachable" in e.lower() for e in report.errors)
|
| 534 |
+
|
| 535 |
+
def test_reachable_evidence_chain(self, validator):
|
| 536 |
+
"""Evidence obtainable through a prerequisite chain should pass."""
|
| 537 |
+
data = _make_minimal_case()
|
| 538 |
+
data["locations"] = [
|
| 539 |
+
{
|
| 540 |
+
"id": "loc1",
|
| 541 |
+
"name": "L1",
|
| 542 |
+
"description": "d",
|
| 543 |
+
"examinables": [
|
| 544 |
+
{"id": "obj1", "name": "O1", "description": "d"},
|
| 545 |
+
{"id": "obj2", "name": "O2", "description": "d"},
|
| 546 |
+
],
|
| 547 |
+
"available_from_start": True,
|
| 548 |
+
}
|
| 549 |
+
]
|
| 550 |
+
data["evidence"].extend(
|
| 551 |
+
[
|
| 552 |
+
{
|
| 553 |
+
"id": "ev3",
|
| 554 |
+
"name": "E3",
|
| 555 |
+
"item_type": "physical",
|
| 556 |
+
"description": "d",
|
| 557 |
+
"detail": "d",
|
| 558 |
+
"source": "investigation",
|
| 559 |
+
"acquisition": {
|
| 560 |
+
"method": "examine",
|
| 561 |
+
"location_id": "loc1",
|
| 562 |
+
"target_id": "obj1",
|
| 563 |
+
"prerequisites": [],
|
| 564 |
+
},
|
| 565 |
+
},
|
| 566 |
+
{
|
| 567 |
+
"id": "ev4",
|
| 568 |
+
"name": "E4",
|
| 569 |
+
"item_type": "physical",
|
| 570 |
+
"description": "d",
|
| 571 |
+
"detail": "d",
|
| 572 |
+
"source": "investigation",
|
| 573 |
+
"acquisition": {
|
| 574 |
+
"method": "examine",
|
| 575 |
+
"location_id": "loc1",
|
| 576 |
+
"target_id": "obj2",
|
| 577 |
+
"prerequisites": ["ev3"],
|
| 578 |
+
},
|
| 579 |
+
},
|
| 580 |
+
]
|
| 581 |
+
)
|
| 582 |
+
case = CaseData(**data)
|
| 583 |
+
report = validator.validate(case)
|
| 584 |
+
assert report.is_valid, f"Errors: {report.errors}"
|
| 585 |
+
|
| 586 |
+
def test_stolen_prototype_reachable(self, validator, stolen_prototype):
|
| 587 |
+
"""All evidence in stolen_prototype should be reachable."""
|
| 588 |
+
errors = validator._validate_reachability(stolen_prototype)
|
| 589 |
+
assert errors == [], f"Reachability errors: {errors}"
|
| 590 |
+
|
| 591 |
+
def test_unreachable_gate_evidence(self, validator):
|
| 592 |
+
"""Investigation gate requiring unobtainable evidence should fail."""
|
| 593 |
+
data = _make_minimal_case()
|
| 594 |
+
data["locations"] = [
|
| 595 |
+
{
|
| 596 |
+
"id": "loc1",
|
| 597 |
+
"name": "L1",
|
| 598 |
+
"description": "d",
|
| 599 |
+
"examinables": [],
|
| 600 |
+
"available_from_start": True,
|
| 601 |
+
}
|
| 602 |
+
]
|
| 603 |
+
# ev3 is investigation evidence but has no way to be obtained
|
| 604 |
+
# (its location does not contain the right examinable)
|
| 605 |
+
data["evidence"].append(
|
| 606 |
+
{
|
| 607 |
+
"id": "ev3",
|
| 608 |
+
"name": "E3",
|
| 609 |
+
"item_type": "physical",
|
| 610 |
+
"description": "d",
|
| 611 |
+
"detail": "d",
|
| 612 |
+
"source": "investigation",
|
| 613 |
+
"acquisition": {
|
| 614 |
+
"method": "examine",
|
| 615 |
+
"location_id": "loc1",
|
| 616 |
+
"target_id": "missing_obj",
|
| 617 |
+
},
|
| 618 |
+
}
|
| 619 |
+
)
|
| 620 |
+
data["investigation_gate"] = {
|
| 621 |
+
"required_evidence": ["ev3"],
|
| 622 |
+
"auto_advance": False,
|
| 623 |
+
}
|
| 624 |
+
case = CaseData(**data)
|
| 625 |
+
report = validator.validate(case)
|
| 626 |
+
assert not report.is_valid
|
| 627 |
+
assert any("unreachable" in e.lower() for e in report.errors)
|
| 628 |
+
|
| 629 |
+
def test_evidence_from_dialogue(self, validator):
|
| 630 |
+
"""Evidence obtainable via dialogue should be reachable."""
|
| 631 |
+
data = _make_minimal_case()
|
| 632 |
+
data["locations"] = [
|
| 633 |
+
{
|
| 634 |
+
"id": "loc1",
|
| 635 |
+
"name": "L1",
|
| 636 |
+
"description": "d",
|
| 637 |
+
"characters_present": ["witness1"],
|
| 638 |
+
"available_from_start": True,
|
| 639 |
+
}
|
| 640 |
+
]
|
| 641 |
+
data["evidence"].append(
|
| 642 |
+
{
|
| 643 |
+
"id": "ev3",
|
| 644 |
+
"name": "E3",
|
| 645 |
+
"item_type": "testimony_record",
|
| 646 |
+
"description": "d",
|
| 647 |
+
"detail": "d",
|
| 648 |
+
"source": "investigation",
|
| 649 |
+
"acquisition": {
|
| 650 |
+
"method": "talk",
|
| 651 |
+
"location_id": "loc1",
|
| 652 |
+
"target_id": "witness1",
|
| 653 |
+
},
|
| 654 |
+
}
|
| 655 |
+
)
|
| 656 |
+
data["dialogues"] = [
|
| 657 |
+
{
|
| 658 |
+
"character_id": "witness1",
|
| 659 |
+
"lines": [
|
| 660 |
+
{
|
| 661 |
+
"id": "line1",
|
| 662 |
+
"text": "Here is some info.",
|
| 663 |
+
"grants_evidence": "ev3",
|
| 664 |
+
}
|
| 665 |
+
],
|
| 666 |
+
"present_responses": [],
|
| 667 |
+
}
|
| 668 |
+
]
|
| 669 |
+
case = CaseData(**data)
|
| 670 |
+
report = validator.validate(case)
|
| 671 |
+
assert report.is_valid, f"Errors: {report.errors}"
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
# ------------------------------------------------------------------
|
| 675 |
+
# Tests: Deadlock detection
|
| 676 |
+
# ------------------------------------------------------------------
|
| 677 |
+
|
| 678 |
+
|
| 679 |
+
class TestDeadlockValidation:
|
| 680 |
+
def test_circular_evidence_prerequisites(self, validator):
|
| 681 |
+
data = _make_minimal_case()
|
| 682 |
+
data["locations"] = [
|
| 683 |
+
{
|
| 684 |
+
"id": "loc1",
|
| 685 |
+
"name": "L1",
|
| 686 |
+
"description": "d",
|
| 687 |
+
"examinables": [
|
| 688 |
+
{"id": "obj1", "name": "O1", "description": "d"},
|
| 689 |
+
{"id": "obj2", "name": "O2", "description": "d"},
|
| 690 |
+
],
|
| 691 |
+
"available_from_start": True,
|
| 692 |
+
}
|
| 693 |
+
]
|
| 694 |
+
# ev3 requires ev4, ev4 requires ev3 -- circular
|
| 695 |
+
data["evidence"].extend(
|
| 696 |
+
[
|
| 697 |
+
{
|
| 698 |
+
"id": "ev3",
|
| 699 |
+
"name": "E3",
|
| 700 |
+
"item_type": "physical",
|
| 701 |
+
"description": "d",
|
| 702 |
+
"detail": "d",
|
| 703 |
+
"source": "investigation",
|
| 704 |
+
"acquisition": {
|
| 705 |
+
"method": "examine",
|
| 706 |
+
"location_id": "loc1",
|
| 707 |
+
"target_id": "obj1",
|
| 708 |
+
"prerequisites": ["ev4"],
|
| 709 |
+
},
|
| 710 |
+
},
|
| 711 |
+
{
|
| 712 |
+
"id": "ev4",
|
| 713 |
+
"name": "E4",
|
| 714 |
+
"item_type": "physical",
|
| 715 |
+
"description": "d",
|
| 716 |
+
"detail": "d",
|
| 717 |
+
"source": "investigation",
|
| 718 |
+
"acquisition": {
|
| 719 |
+
"method": "examine",
|
| 720 |
+
"location_id": "loc1",
|
| 721 |
+
"target_id": "obj2",
|
| 722 |
+
"prerequisites": ["ev3"],
|
| 723 |
+
},
|
| 724 |
+
},
|
| 725 |
+
]
|
| 726 |
+
)
|
| 727 |
+
case = CaseData(**data)
|
| 728 |
+
report = validator.validate(case)
|
| 729 |
+
assert not report.is_valid
|
| 730 |
+
assert any("circular" in e.lower() for e in report.errors)
|
| 731 |
+
|
| 732 |
+
def test_disconnected_location(self, validator):
|
| 733 |
+
data = _make_minimal_case()
|
| 734 |
+
data["locations"] = [
|
| 735 |
+
{
|
| 736 |
+
"id": "loc1",
|
| 737 |
+
"name": "L1",
|
| 738 |
+
"description": "d",
|
| 739 |
+
"connected_to": [],
|
| 740 |
+
"available_from_start": True,
|
| 741 |
+
},
|
| 742 |
+
{
|
| 743 |
+
"id": "loc2",
|
| 744 |
+
"name": "L2",
|
| 745 |
+
"description": "d",
|
| 746 |
+
"connected_to": [],
|
| 747 |
+
"available_from_start": False,
|
| 748 |
+
"unlock_prerequisites": [],
|
| 749 |
+
},
|
| 750 |
+
]
|
| 751 |
+
case = CaseData(**data)
|
| 752 |
+
report = validator.validate(case)
|
| 753 |
+
assert not report.is_valid
|
| 754 |
+
assert any("not reachable" in e.lower() for e in report.errors)
|
| 755 |
+
|
| 756 |
+
def test_no_deadlocks_in_stolen_prototype(self, validator, stolen_prototype):
|
| 757 |
+
errors = validator._validate_no_deadlocks(stolen_prototype)
|
| 758 |
+
assert errors == [], f"Deadlock errors: {errors}"
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
# ------------------------------------------------------------------
|
| 762 |
+
# Tests: Structural warnings
|
| 763 |
+
# ------------------------------------------------------------------
|
| 764 |
+
|
| 765 |
+
|
| 766 |
+
class TestStructuralWarnings:
|
| 767 |
+
def test_no_defendant_warning(self, validator):
|
| 768 |
+
data = _make_minimal_case()
|
| 769 |
+
data["characters"] = [
|
| 770 |
+
{"id": "npc1", "name": "N", "description": "n", "role": "npc"},
|
| 771 |
+
{"id": "witness1", "name": "W", "description": "w", "role": "witness"},
|
| 772 |
+
]
|
| 773 |
+
case = CaseData(**data)
|
| 774 |
+
report = validator.validate(case)
|
| 775 |
+
assert any("defendant" in w.lower() for w in report.warnings)
|
| 776 |
+
|
| 777 |
+
def test_hard_mode_no_locations_warning(self, validator):
|
| 778 |
+
data = _make_minimal_case(difficulty="hard")
|
| 779 |
+
case = CaseData(**data)
|
| 780 |
+
report = validator.validate(case)
|
| 781 |
+
assert any("locations" in w.lower() for w in report.warnings)
|
| 782 |
+
|
| 783 |
+
def test_hard_mode_no_dialogues_warning(self, validator):
|
| 784 |
+
data = _make_minimal_case(difficulty="hard")
|
| 785 |
+
data["locations"] = [
|
| 786 |
+
{
|
| 787 |
+
"id": "loc1",
|
| 788 |
+
"name": "L1",
|
| 789 |
+
"description": "d",
|
| 790 |
+
"available_from_start": True,
|
| 791 |
+
}
|
| 792 |
+
]
|
| 793 |
+
case = CaseData(**data)
|
| 794 |
+
report = validator.validate(case)
|
| 795 |
+
assert any("dialogues" in w.lower() for w in report.warnings)
|
| 796 |
+
|
| 797 |
+
def test_hard_mode_no_gate_warning(self, validator):
|
| 798 |
+
data = _make_minimal_case(difficulty="hard")
|
| 799 |
+
data["locations"] = [
|
| 800 |
+
{
|
| 801 |
+
"id": "loc1",
|
| 802 |
+
"name": "L1",
|
| 803 |
+
"description": "d",
|
| 804 |
+
"available_from_start": True,
|
| 805 |
+
}
|
| 806 |
+
]
|
| 807 |
+
data["dialogues"] = []
|
| 808 |
+
case = CaseData(**data)
|
| 809 |
+
report = validator.validate(case)
|
| 810 |
+
assert any("investigation gate" in w.lower() for w in report.warnings)
|
| 811 |
+
|
| 812 |
+
def test_isolated_location_warning(self, validator):
|
| 813 |
+
data = _make_minimal_case()
|
| 814 |
+
data["locations"] = [
|
| 815 |
+
{
|
| 816 |
+
"id": "loc1",
|
| 817 |
+
"name": "L1",
|
| 818 |
+
"description": "d",
|
| 819 |
+
"connected_to": [],
|
| 820 |
+
"available_from_start": True,
|
| 821 |
+
},
|
| 822 |
+
{
|
| 823 |
+
"id": "loc2",
|
| 824 |
+
"name": "L2",
|
| 825 |
+
"description": "d",
|
| 826 |
+
"connected_to": [],
|
| 827 |
+
"available_from_start": True,
|
| 828 |
+
},
|
| 829 |
+
]
|
| 830 |
+
case = CaseData(**data)
|
| 831 |
+
report = validator.validate(case)
|
| 832 |
+
assert any("no connections" in w.lower() for w in report.warnings)
|
| 833 |
+
|
| 834 |
+
|
| 835 |
+
# ------------------------------------------------------------------
|
| 836 |
+
# Tests: Full validate() integration
|
| 837 |
+
# ------------------------------------------------------------------
|
| 838 |
+
|
| 839 |
+
|
| 840 |
+
class TestFullValidation:
|
| 841 |
+
def test_stolen_prototype_full_validation(self, validator, stolen_prototype):
|
| 842 |
+
report = validator.validate(stolen_prototype)
|
| 843 |
+
assert report.is_valid, f"Errors: {report.errors}"
|
| 844 |
+
# Stolen prototype is a well-formed hard case; expect no warnings
|
| 845 |
+
# (or at most minor ones)
|
| 846 |
+
|
| 847 |
+
def test_multiple_errors_reported(self, validator):
|
| 848 |
+
"""Multiple issues should all be reported."""
|
| 849 |
+
data = _make_minimal_case()
|
| 850 |
+
data["court"]["win_condition"]["required_contradiction_ids"] = [
|
| 851 |
+
"bad_stmt_1",
|
| 852 |
+
"bad_stmt_2",
|
| 853 |
+
]
|
| 854 |
+
case = CaseData(**data)
|
| 855 |
+
report = validator.validate(case)
|
| 856 |
+
assert not report.is_valid
|
| 857 |
+
assert len(report.errors) >= 2
|
| 858 |
+
|
| 859 |
+
def test_clean_minimal_case(self, validator):
|
| 860 |
+
case = _make_minimal_case_data()
|
| 861 |
+
report = validator.validate(case)
|
| 862 |
+
assert report.is_valid
|
| 863 |
+
assert report.errors == []
|
turnabout/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Turnabout: Ace Attorney-inspired environment for agent long-horizon reasoning."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.0"
|
turnabout/agents/__init__.py
ADDED
|
File without changes
|
turnabout/agents/llm_agent.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM agent baseline using text environment."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from turnabout.envs.text_env import TextCourtEnv
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class LLMAgent:
|
| 12 |
+
"""Agent that uses an LLM (via Anthropic SDK) to play the game."""
|
| 13 |
+
|
| 14 |
+
def __init__(
|
| 15 |
+
self,
|
| 16 |
+
client: Any = None,
|
| 17 |
+
model: str = "claude-sonnet-4-6",
|
| 18 |
+
max_tokens: int = 512,
|
| 19 |
+
temperature: float = 0.0,
|
| 20 |
+
):
|
| 21 |
+
if client is None:
|
| 22 |
+
try:
|
| 23 |
+
from anthropic import Anthropic
|
| 24 |
+
client = Anthropic()
|
| 25 |
+
except ImportError:
|
| 26 |
+
raise ImportError(
|
| 27 |
+
"anthropic package required. Install with: pip install anthropic"
|
| 28 |
+
)
|
| 29 |
+
self.client = client
|
| 30 |
+
self.model = model
|
| 31 |
+
self.max_tokens = max_tokens
|
| 32 |
+
self.temperature = temperature
|
| 33 |
+
|
| 34 |
+
def run_episode(
|
| 35 |
+
self, env: TextCourtEnv, verbose: bool = False
|
| 36 |
+
) -> dict:
|
| 37 |
+
obs = env.reset()
|
| 38 |
+
system = env.system_prompt
|
| 39 |
+
messages: list[dict] = []
|
| 40 |
+
|
| 41 |
+
if verbose:
|
| 42 |
+
print(obs)
|
| 43 |
+
|
| 44 |
+
messages.append({"role": "user", "content": obs})
|
| 45 |
+
|
| 46 |
+
total_reward = 0.0
|
| 47 |
+
done = False
|
| 48 |
+
|
| 49 |
+
while not done:
|
| 50 |
+
response = self.client.messages.create(
|
| 51 |
+
model=self.model,
|
| 52 |
+
max_tokens=self.max_tokens,
|
| 53 |
+
temperature=self.temperature,
|
| 54 |
+
system=system,
|
| 55 |
+
messages=messages,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
action_text = response.content[0].text.strip()
|
| 59 |
+
action_text = self._extract_action(action_text)
|
| 60 |
+
|
| 61 |
+
messages.append({"role": "assistant", "content": action_text})
|
| 62 |
+
|
| 63 |
+
obs, reward, done, info = env.step(action_text)
|
| 64 |
+
total_reward += reward
|
| 65 |
+
|
| 66 |
+
if verbose:
|
| 67 |
+
print(f"\n> {action_text}")
|
| 68 |
+
print(obs[:300])
|
| 69 |
+
|
| 70 |
+
messages.append({"role": "user", "content": obs})
|
| 71 |
+
|
| 72 |
+
if len(messages) > 40:
|
| 73 |
+
messages = messages[:2] + messages[-20:]
|
| 74 |
+
|
| 75 |
+
metrics = env.get_metrics()
|
| 76 |
+
return {
|
| 77 |
+
"won": metrics.won,
|
| 78 |
+
"total_reward": total_reward,
|
| 79 |
+
"steps": metrics.total_steps,
|
| 80 |
+
"contradiction_accuracy": metrics.contradiction_accuracy,
|
| 81 |
+
"evidence_coverage": metrics.evidence_coverage,
|
| 82 |
+
"composite_score": metrics.composite_score,
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
def _extract_action(self, text: str) -> str:
|
| 86 |
+
"""Extract the actual command from LLM output (may include reasoning)."""
|
| 87 |
+
lines = text.strip().split("\n")
|
| 88 |
+
for line in reversed(lines):
|
| 89 |
+
line = line.strip()
|
| 90 |
+
if line.startswith(">"):
|
| 91 |
+
return line[1:].strip()
|
| 92 |
+
if line.startswith("Action:"):
|
| 93 |
+
return line[7:].strip()
|
| 94 |
+
if line.startswith("Command:"):
|
| 95 |
+
return line[8:].strip()
|
| 96 |
+
return lines[-1].strip()
|
turnabout/agents/random_agent.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Random baseline agent using action masking."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import random
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from turnabout.core.actions import Action
|
| 10 |
+
from turnabout.envs.text_env import TextCourtEnv
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class RandomAgent:
|
| 14 |
+
def __init__(self, seed: int | None = None):
|
| 15 |
+
self.rng = random.Random(seed)
|
| 16 |
+
|
| 17 |
+
def run_episode(
|
| 18 |
+
self, env: TextCourtEnv, verbose: bool = False
|
| 19 |
+
) -> dict:
|
| 20 |
+
obs = env.reset()
|
| 21 |
+
if verbose:
|
| 22 |
+
print(obs)
|
| 23 |
+
|
| 24 |
+
total_reward = 0.0
|
| 25 |
+
done = False
|
| 26 |
+
|
| 27 |
+
while not done:
|
| 28 |
+
valid_actions = env.engine.get_valid_actions()
|
| 29 |
+
if not valid_actions:
|
| 30 |
+
break
|
| 31 |
+
|
| 32 |
+
action = self.rng.choice(valid_actions)
|
| 33 |
+
state, engine_obs, reward, engine_done, _ = env.engine.step(action)
|
| 34 |
+
env._step_count += 1
|
| 35 |
+
env.metrics_computer.accumulate_reward(reward)
|
| 36 |
+
total_reward += reward
|
| 37 |
+
done = engine_done or env._step_count >= env.max_steps
|
| 38 |
+
|
| 39 |
+
if verbose:
|
| 40 |
+
print(f"\n> {action}")
|
| 41 |
+
print(engine_obs[:300])
|
| 42 |
+
|
| 43 |
+
metrics = env.get_metrics()
|
| 44 |
+
return {
|
| 45 |
+
"won": metrics.won,
|
| 46 |
+
"total_reward": total_reward,
|
| 47 |
+
"steps": metrics.total_steps,
|
| 48 |
+
"contradiction_accuracy": metrics.contradiction_accuracy,
|
| 49 |
+
"evidence_coverage": metrics.evidence_coverage,
|
| 50 |
+
"composite_score": metrics.composite_score,
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def run_benchmark(
|
| 55 |
+
case_path: str,
|
| 56 |
+
difficulty: str = "easy",
|
| 57 |
+
n_episodes: int = 100,
|
| 58 |
+
seed: int = 42,
|
| 59 |
+
verbose: bool = False,
|
| 60 |
+
) -> dict:
|
| 61 |
+
agent = RandomAgent(seed=seed)
|
| 62 |
+
results = []
|
| 63 |
+
|
| 64 |
+
for i in range(n_episodes):
|
| 65 |
+
agent.rng = random.Random(seed + i)
|
| 66 |
+
env = TextCourtEnv(case_path=case_path, difficulty=difficulty)
|
| 67 |
+
result = agent.run_episode(env, verbose=verbose and i == 0)
|
| 68 |
+
results.append(result)
|
| 69 |
+
|
| 70 |
+
wins = sum(r["won"] for r in results)
|
| 71 |
+
avg_reward = sum(r["total_reward"] for r in results) / n_episodes
|
| 72 |
+
avg_steps = sum(r["steps"] for r in results) / n_episodes
|
| 73 |
+
avg_score = sum(r["composite_score"] for r in results) / n_episodes
|
| 74 |
+
|
| 75 |
+
return {
|
| 76 |
+
"n_episodes": n_episodes,
|
| 77 |
+
"win_rate": wins / n_episodes,
|
| 78 |
+
"avg_reward": avg_reward,
|
| 79 |
+
"avg_steps": avg_steps,
|
| 80 |
+
"avg_composite_score": avg_score,
|
| 81 |
+
}
|
turnabout/cases/stolen_prototype.json
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "stolen_prototype",
|
| 3 |
+
"title": "The Stolen Prototype",
|
| 4 |
+
"difficulty": "hard",
|
| 5 |
+
"description": "A revolutionary quantum chip has been stolen from QuantumTech Labs, and junior researcher Alex Park stands accused. With keycard logs pointing squarely at the defendant, only a thorough investigation can uncover the truth behind this high-tech heist.",
|
| 6 |
+
"metadata": {
|
| 7 |
+
"author": "human",
|
| 8 |
+
"version": "1.0",
|
| 9 |
+
"tags": ["corporate", "technology", "theft", "framing"]
|
| 10 |
+
},
|
| 11 |
+
"characters": [
|
| 12 |
+
{
|
| 13 |
+
"id": "alex_park",
|
| 14 |
+
"name": "Alex Park",
|
| 15 |
+
"description": "A junior researcher at QuantumTech Labs. Quiet and dedicated, Alex has always kept his head down and focused on his work.",
|
| 16 |
+
"role": "defendant"
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"id": "morrison",
|
| 20 |
+
"name": "Morrison",
|
| 21 |
+
"description": "Night security guard at QuantumTech Labs with 10 years of service. Proud of his long tenure but perhaps a bit too comfortable in his routine.",
|
| 22 |
+
"role": "witness"
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"id": "chen",
|
| 26 |
+
"name": "Dr. Sarah Chen",
|
| 27 |
+
"description": "Lead researcher at QuantumTech Labs and Alex Park's direct supervisor. A brilliant scientist who believes in her team.",
|
| 28 |
+
"role": "npc"
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"id": "hale",
|
| 32 |
+
"name": "Dr. Victor Hale",
|
| 33 |
+
"description": "Senior researcher at QuantumTech Labs. Ambitious and competitive, Hale has long coveted the lead researcher position.",
|
| 34 |
+
"role": "npc"
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"id": "judge",
|
| 38 |
+
"name": "The Judge",
|
| 39 |
+
"description": "The presiding judge of this trial. Stern but fair, with little patience for theatrics — unless they reveal the truth.",
|
| 40 |
+
"role": "judge"
|
| 41 |
+
}
|
| 42 |
+
],
|
| 43 |
+
"evidence": [
|
| 44 |
+
{
|
| 45 |
+
"id": "incident_report",
|
| 46 |
+
"name": "Incident Report",
|
| 47 |
+
"item_type": "document",
|
| 48 |
+
"description": "Official QuantumTech Labs security incident report filed on March 15.",
|
| 49 |
+
"detail": "Break-in detected at 11:30 PM on March 15. The quantum chip prototype was confirmed missing from Secure Case #3 in the east wing laboratory. No signs of forced entry were found — keycard access was used to enter the lab.",
|
| 50 |
+
"source": "pre_given",
|
| 51 |
+
"acquisition": null
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"id": "keycard_log",
|
| 55 |
+
"name": "Keycard Access Log",
|
| 56 |
+
"item_type": "document",
|
| 57 |
+
"description": "Electronic log of all keycard entries to the east wing laboratory.",
|
| 58 |
+
"detail": "Keycard #0247, registered to Alex Park, was used at Lab Door East at 11:15:03 PM on March 15. This is the only keycard entry recorded between 9:00 PM and midnight.",
|
| 59 |
+
"source": "pre_given",
|
| 60 |
+
"acquisition": null
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"id": "security_footage",
|
| 64 |
+
"name": "Security Camera Footage",
|
| 65 |
+
"item_type": "document",
|
| 66 |
+
"description": "Recovered footage from the east wing security camera.",
|
| 67 |
+
"detail": "The footage shows a hooded figure entering the lab at 11:15 PM and exiting at 11:25 PM carrying a small protective case. The figure's face is completely obscured by the hood, making positive identification impossible from the video alone.",
|
| 68 |
+
"source": "investigation",
|
| 69 |
+
"acquisition": {
|
| 70 |
+
"method": "examine",
|
| 71 |
+
"location_id": "security_office",
|
| 72 |
+
"target_id": "monitor_desk",
|
| 73 |
+
"prerequisites": [],
|
| 74 |
+
"present_evidence_id": null
|
| 75 |
+
}
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"id": "alex_alibi",
|
| 79 |
+
"name": "Alex's Bus Pass Record",
|
| 80 |
+
"item_type": "document",
|
| 81 |
+
"description": "Digital bus pass transaction record for Alex Park.",
|
| 82 |
+
"detail": "Alex Park's digital bus pass was scanned at Downtown Station at 10:50 PM on March 15. The next bus from Downtown Station to the QuantumTech area does not arrive until 11:55 PM — forty minutes AFTER the lab entry was recorded.",
|
| 83 |
+
"source": "investigation",
|
| 84 |
+
"acquisition": {
|
| 85 |
+
"method": "talk",
|
| 86 |
+
"location_id": "detention_center",
|
| 87 |
+
"target_id": "alex_park",
|
| 88 |
+
"prerequisites": [],
|
| 89 |
+
"present_evidence_id": null
|
| 90 |
+
}
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"id": "cloning_device",
|
| 94 |
+
"name": "RFID Cloning Device",
|
| 95 |
+
"item_type": "physical",
|
| 96 |
+
"description": "A handheld RFID cloning device found hidden in Dr. Hale's desk.",
|
| 97 |
+
"detail": "A sophisticated RFID keycard cloner. Internal logs show it was last used on March 14 — the day before the theft. The cloned data stored in its memory is a perfect match for keycard #0247, registered to Alex Park.",
|
| 98 |
+
"source": "investigation",
|
| 99 |
+
"acquisition": {
|
| 100 |
+
"method": "examine",
|
| 101 |
+
"location_id": "hale_office",
|
| 102 |
+
"target_id": "hale_desk",
|
| 103 |
+
"prerequisites": ["hale_suspicion"],
|
| 104 |
+
"present_evidence_id": null
|
| 105 |
+
}
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"id": "hale_suspicion",
|
| 109 |
+
"name": "Dr. Chen's Testimony About Hale",
|
| 110 |
+
"item_type": "testimony_record",
|
| 111 |
+
"description": "Dr. Chen's account of Dr. Hale's suspicious behavior in the days before the theft.",
|
| 112 |
+
"detail": "Dr. Chen recalls that Dr. Victor Hale had been asking unusually detailed questions about the lab's security protocols, keycard systems, and camera placement in the week leading up to the theft. She found it odd at the time but didn't think much of it until seeing the security footage.",
|
| 113 |
+
"source": "investigation",
|
| 114 |
+
"acquisition": {
|
| 115 |
+
"method": "present",
|
| 116 |
+
"location_id": "lab",
|
| 117 |
+
"target_id": "chen",
|
| 118 |
+
"prerequisites": ["security_footage"],
|
| 119 |
+
"present_evidence_id": "security_footage"
|
| 120 |
+
}
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"id": "patrol_schedule",
|
| 124 |
+
"name": "Security Patrol Protocol",
|
| 125 |
+
"item_type": "document",
|
| 126 |
+
"description": "Official QuantumTech Labs security patrol schedule and protocol document.",
|
| 127 |
+
"detail": "The official protocol mandates east wing patrols every 30 MINUTES, not every hour. The document is signed by the head of security and dated January of this year. Morrison's claim of hourly patrols directly contradicts his own employer's standing orders.",
|
| 128 |
+
"source": "investigation",
|
| 129 |
+
"acquisition": {
|
| 130 |
+
"method": "examine",
|
| 131 |
+
"location_id": "security_office",
|
| 132 |
+
"target_id": "filing_cabinet",
|
| 133 |
+
"prerequisites": [],
|
| 134 |
+
"present_evidence_id": null
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
],
|
| 138 |
+
"locations": [
|
| 139 |
+
{
|
| 140 |
+
"id": "detention_center",
|
| 141 |
+
"name": "Detention Center",
|
| 142 |
+
"description": "A stark, sterile visiting room at the city detention center. Alex Park sits behind reinforced glass, looking exhausted and frightened.",
|
| 143 |
+
"connected_to": ["lab"],
|
| 144 |
+
"examinables": [],
|
| 145 |
+
"characters_present": ["alex_park"],
|
| 146 |
+
"available_from_start": true,
|
| 147 |
+
"unlock_prerequisites": []
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
"id": "lab",
|
| 151 |
+
"name": "QuantumTech East Wing Laboratory",
|
| 152 |
+
"description": "A state-of-the-art research lab filled with delicate instruments and humming servers. Yellow crime scene tape still marks the area around Secure Case #3, now empty.",
|
| 153 |
+
"connected_to": ["detention_center", "security_office", "main_hallway"],
|
| 154 |
+
"examinables": [
|
| 155 |
+
{
|
| 156 |
+
"id": "secure_case",
|
| 157 |
+
"name": "Secure Case #3",
|
| 158 |
+
"description": "A reinforced display case designed to hold the quantum chip prototype. The glass is intact — it was opened with an authorized code.",
|
| 159 |
+
"examined_description": "The case shows no signs of tampering. Whoever took the prototype knew the access code or had a way to bypass it cleanly."
|
| 160 |
+
},
|
| 161 |
+
{
|
| 162 |
+
"id": "workbench",
|
| 163 |
+
"name": "Research Workbench",
|
| 164 |
+
"description": "Alex Park's assigned workstation, cluttered with notes and schematics.",
|
| 165 |
+
"examined_description": "The notes are all related to quantum error correction — Alex's assigned research topic. Nothing here suggests any interest in stealing the prototype."
|
| 166 |
+
}
|
| 167 |
+
],
|
| 168 |
+
"characters_present": ["chen"],
|
| 169 |
+
"available_from_start": true,
|
| 170 |
+
"unlock_prerequisites": []
|
| 171 |
+
},
|
| 172 |
+
{
|
| 173 |
+
"id": "security_office",
|
| 174 |
+
"name": "Security Office",
|
| 175 |
+
"description": "The QuantumTech security hub. Banks of monitors line one wall, and a filing cabinet stands in the corner. A half-empty coffee mug sits on the desk.",
|
| 176 |
+
"connected_to": ["lab", "main_hallway"],
|
| 177 |
+
"examinables": [
|
| 178 |
+
{
|
| 179 |
+
"id": "monitor_desk",
|
| 180 |
+
"name": "Security Monitor Desk",
|
| 181 |
+
"description": "Multiple screens showing camera feeds from around the facility. A recording system hums beneath the desk.",
|
| 182 |
+
"examined_description": "You scrub through the footage from March 15. At 11:15 PM, a hooded figure enters the east wing lab. At 11:25 PM, the same figure exits carrying a small case. The face is never visible."
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
"id": "filing_cabinet",
|
| 186 |
+
"name": "Filing Cabinet",
|
| 187 |
+
"description": "A metal filing cabinet labeled 'PROTOCOLS & SCHEDULES'. Looks like it hasn't been opened in a while.",
|
| 188 |
+
"examined_description": "Inside, you find the official patrol protocol. It clearly states: east wing patrols are to be conducted every 30 minutes. Someone hasn't been following the rules."
|
| 189 |
+
}
|
| 190 |
+
],
|
| 191 |
+
"characters_present": [],
|
| 192 |
+
"available_from_start": true,
|
| 193 |
+
"unlock_prerequisites": []
|
| 194 |
+
},
|
| 195 |
+
{
|
| 196 |
+
"id": "main_hallway",
|
| 197 |
+
"name": "Main Hallway",
|
| 198 |
+
"description": "The central corridor of QuantumTech Labs. Fluorescent lights buzz overhead, and employee photos line the walls. Dr. Hale's office is visible at the far end.",
|
| 199 |
+
"connected_to": ["lab", "security_office", "hale_office"],
|
| 200 |
+
"examinables": [],
|
| 201 |
+
"characters_present": [],
|
| 202 |
+
"available_from_start": true,
|
| 203 |
+
"unlock_prerequisites": []
|
| 204 |
+
},
|
| 205 |
+
{
|
| 206 |
+
"id": "hale_office",
|
| 207 |
+
"name": "Dr. Hale's Office",
|
| 208 |
+
"description": "A private office belonging to Dr. Victor Hale. Immaculately organized, with awards and patents displayed prominently on the walls. The desk drawers are conspicuously locked.",
|
| 209 |
+
"connected_to": ["main_hallway"],
|
| 210 |
+
"examinables": [
|
| 211 |
+
{
|
| 212 |
+
"id": "hale_desk",
|
| 213 |
+
"name": "Dr. Hale's Desk",
|
| 214 |
+
"description": "An expensive mahogany desk. The locked bottom drawer has a faint scratch around the keyhole, as if it's been opened and closed frequently.",
|
| 215 |
+
"examined_description": "You manage to open the locked drawer and discover a handheld RFID cloning device! Its internal log shows it was last used on March 14, and the cloned data matches Alex Park's keycard #0247 exactly."
|
| 216 |
+
}
|
| 217 |
+
],
|
| 218 |
+
"characters_present": ["hale"],
|
| 219 |
+
"available_from_start": false,
|
| 220 |
+
"unlock_prerequisites": ["hale_suspicion"]
|
| 221 |
+
}
|
| 222 |
+
],
|
| 223 |
+
"dialogues": [
|
| 224 |
+
{
|
| 225 |
+
"character_id": "alex_park",
|
| 226 |
+
"lines": [
|
| 227 |
+
{
|
| 228 |
+
"id": "alex_intro",
|
| 229 |
+
"text": "I didn't do it! I wasn't even at the lab that night. I was downtown until late.",
|
| 230 |
+
"prerequisites": [],
|
| 231 |
+
"grants_evidence": null
|
| 232 |
+
},
|
| 233 |
+
{
|
| 234 |
+
"id": "alex_bus",
|
| 235 |
+
"text": "I took the bus home. I have a digital bus pass - it should show when I scanned it!",
|
| 236 |
+
"prerequisites": [],
|
| 237 |
+
"grants_evidence": "alex_alibi"
|
| 238 |
+
}
|
| 239 |
+
],
|
| 240 |
+
"present_responses": []
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"character_id": "chen",
|
| 244 |
+
"lines": [
|
| 245 |
+
{
|
| 246 |
+
"id": "chen_intro",
|
| 247 |
+
"text": "Alex is a good researcher. I can't believe he would steal the prototype. But the keycard evidence...",
|
| 248 |
+
"prerequisites": [],
|
| 249 |
+
"grants_evidence": null
|
| 250 |
+
},
|
| 251 |
+
{
|
| 252 |
+
"id": "chen_hale_info",
|
| 253 |
+
"text": "Now that I think about it, Dr. Hale has been acting strangely lately. He's been staying late and asking about security protocols.",
|
| 254 |
+
"prerequisites": ["security_footage"],
|
| 255 |
+
"grants_evidence": null
|
| 256 |
+
}
|
| 257 |
+
],
|
| 258 |
+
"present_responses": [
|
| 259 |
+
{
|
| 260 |
+
"evidence_id": "security_footage",
|
| 261 |
+
"text": "That hooded figure... the build looks like it could be Victor Hale too. And he's been asking me strange questions about our security lately.",
|
| 262 |
+
"grants_evidence": "hale_suspicion",
|
| 263 |
+
"unlocks_dialogue_line": "chen_hale_info"
|
| 264 |
+
}
|
| 265 |
+
]
|
| 266 |
+
},
|
| 267 |
+
{
|
| 268 |
+
"character_id": "hale",
|
| 269 |
+
"lines": [
|
| 270 |
+
{
|
| 271 |
+
"id": "hale_intro",
|
| 272 |
+
"text": "Terrible business, this theft. Alex always seemed a bit... envious of the project leads. I'm not surprised, frankly.",
|
| 273 |
+
"prerequisites": [],
|
| 274 |
+
"grants_evidence": null
|
| 275 |
+
}
|
| 276 |
+
],
|
| 277 |
+
"present_responses": []
|
| 278 |
+
}
|
| 279 |
+
],
|
| 280 |
+
"court": {
|
| 281 |
+
"penalty_limit": 5,
|
| 282 |
+
"rounds": [
|
| 283 |
+
{
|
| 284 |
+
"witness_id": "morrison",
|
| 285 |
+
"order": 0,
|
| 286 |
+
"initial_testimony_id": "morrison_patrol",
|
| 287 |
+
"testimonies": [
|
| 288 |
+
{
|
| 289 |
+
"id": "morrison_patrol",
|
| 290 |
+
"title": "My Patrol That Night",
|
| 291 |
+
"witness_id": "morrison",
|
| 292 |
+
"preamble": "The witness, Morrison, will now testify about his patrol duties on the night of March 15.",
|
| 293 |
+
"statements": [
|
| 294 |
+
{
|
| 295 |
+
"id": "patrol_1",
|
| 296 |
+
"text": "I patrol the east wing every hour on the hour. That's standard protocol.",
|
| 297 |
+
"press_response": "It's what we've always done. Every hour, like clockwork. I've been doing this job for ten years — I know the drill better than anyone.",
|
| 298 |
+
"press_reveals_statement": null,
|
| 299 |
+
"press_grants_evidence": null,
|
| 300 |
+
"contradiction": {
|
| 301 |
+
"evidence_id": "patrol_schedule",
|
| 302 |
+
"explanation": "OBJECTION! The witness claims patrols are conducted every HOUR — but the official QuantumTech security protocol clearly states that east wing patrols must be conducted every THIRTY MINUTES! Morrison, you've been cutting your patrols in half! That means there was a much larger window of opportunity for the real thief than you've led this court to believe!",
|
| 303 |
+
"is_primary": true,
|
| 304 |
+
"triggers_testimony": "morrison_revised"
|
| 305 |
+
}
|
| 306 |
+
},
|
| 307 |
+
{
|
| 308 |
+
"id": "patrol_2",
|
| 309 |
+
"text": "At 11:00 PM, I made my regular pass. Everything was normal in the lab.",
|
| 310 |
+
"press_response": "I looked through the window. The prototype was still in its case. Nobody was inside. Everything was quiet — just another normal night. Or so I thought.",
|
| 311 |
+
"press_reveals_statement": null,
|
| 312 |
+
"press_grants_evidence": null,
|
| 313 |
+
"contradiction": null
|
| 314 |
+
},
|
| 315 |
+
{
|
| 316 |
+
"id": "patrol_3",
|
| 317 |
+
"text": "The alarm went off at 11:30 PM. I rushed straight to the lab.",
|
| 318 |
+
"press_response": "I was at my desk when I heard it. Took me about two minutes to get there. My heart was pounding — in ten years, we've never had a real breach before.",
|
| 319 |
+
"press_reveals_statement": null,
|
| 320 |
+
"press_grants_evidence": null,
|
| 321 |
+
"contradiction": null
|
| 322 |
+
},
|
| 323 |
+
{
|
| 324 |
+
"id": "patrol_4",
|
| 325 |
+
"text": "When I arrived, the door was open and the prototype case was empty.",
|
| 326 |
+
"press_response": "No sign of forced entry. Whoever did it had a keycard. The case was wide open, prototype gone. That's when I called the police.",
|
| 327 |
+
"press_reveals_statement": null,
|
| 328 |
+
"press_grants_evidence": null,
|
| 329 |
+
"contradiction": null
|
| 330 |
+
}
|
| 331 |
+
]
|
| 332 |
+
},
|
| 333 |
+
{
|
| 334 |
+
"id": "morrison_revised",
|
| 335 |
+
"title": "The Truth About My Patrol",
|
| 336 |
+
"witness_id": "morrison",
|
| 337 |
+
"preamble": "The witness has admitted to neglecting patrol protocol. He will now provide revised testimony about what he actually saw that night.",
|
| 338 |
+
"statements": [
|
| 339 |
+
{
|
| 340 |
+
"id": "revised_1",
|
| 341 |
+
"text": "Fine... I admit I sometimes skip patrols. But I was definitely awake the whole time!",
|
| 342 |
+
"press_response": "I just... rest my eyes sometimes. But I was at my desk! I would have noticed if anything was wrong. ...Probably.",
|
| 343 |
+
"press_reveals_statement": null,
|
| 344 |
+
"press_grants_evidence": null,
|
| 345 |
+
"contradiction": null
|
| 346 |
+
},
|
| 347 |
+
{
|
| 348 |
+
"id": "revised_2",
|
| 349 |
+
"text": "I saw the defendant enter the lab on the security camera at 11:15 PM.",
|
| 350 |
+
"press_response": "Well, I saw someone enter. It was definitely the defendant's keycard that was used. The log doesn't lie!",
|
| 351 |
+
"press_reveals_statement": "revised_2b",
|
| 352 |
+
"press_grants_evidence": null,
|
| 353 |
+
"contradiction": null
|
| 354 |
+
},
|
| 355 |
+
{
|
| 356 |
+
"id": "revised_2b",
|
| 357 |
+
"text": "I mean... the camera showed someone in a hood. But the keycard log showed it was the defendant's card.",
|
| 358 |
+
"press_response": "A keycard is a keycard. If it says Alex Park, it was Alex Park. Those systems don't make mistakes!",
|
| 359 |
+
"press_reveals_statement": null,
|
| 360 |
+
"press_grants_evidence": null,
|
| 361 |
+
"contradiction": {
|
| 362 |
+
"evidence_id": "cloning_device",
|
| 363 |
+
"explanation": "OBJECTION! A keycard is NOT always what it seems! This RFID cloning device was found in Dr. Hale's office — and its internal log proves it was used to create a PERFECT COPY of Alex Park's keycard #0247 on March 14, the day BEFORE the theft! The keycard system didn't make a mistake — it was deliberately DECEIVED! Someone cloned the defendant's card to frame him for a crime he didn't commit!",
|
| 364 |
+
"is_primary": true,
|
| 365 |
+
"triggers_testimony": null
|
| 366 |
+
}
|
| 367 |
+
},
|
| 368 |
+
{
|
| 369 |
+
"id": "revised_3",
|
| 370 |
+
"text": "There's no way anyone else could have gotten into that lab. Only authorized keycards work.",
|
| 371 |
+
"press_response": "The system is foolproof. Whoever's card it shows, that's who was there. There's simply no other explanation!",
|
| 372 |
+
"press_reveals_statement": null,
|
| 373 |
+
"press_grants_evidence": null,
|
| 374 |
+
"contradiction": {
|
| 375 |
+
"evidence_id": "alex_alibi",
|
| 376 |
+
"explanation": "OBJECTION! The witness claims only Alex Park could have entered that lab — but that is a PHYSICAL IMPOSSIBILITY! Alex Park's bus pass was scanned at Downtown Station at 10:50 PM. The next bus to the QuantumTech area doesn't arrive until 11:55 PM! The lab entry was recorded at 11:15 PM! Unless my client can teleport, he could NOT have been the one who walked through that door! Someone ELSE used a card with his name on it!",
|
| 377 |
+
"is_primary": true,
|
| 378 |
+
"triggers_testimony": null
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
]
|
| 382 |
+
}
|
| 383 |
+
]
|
| 384 |
+
}
|
| 385 |
+
],
|
| 386 |
+
"win_condition": {
|
| 387 |
+
"required_contradiction_ids": ["patrol_1", "revised_2b", "revised_3"],
|
| 388 |
+
"final_evidence_id": "cloning_device"
|
| 389 |
+
}
|
| 390 |
+
},
|
| 391 |
+
"investigation_gate": {
|
| 392 |
+
"required_evidence": ["security_footage", "alex_alibi", "patrol_schedule", "cloning_device"],
|
| 393 |
+
"auto_advance": false
|
| 394 |
+
}
|
| 395 |
+
}
|
turnabout/core/__init__.py
ADDED
|
File without changes
|
turnabout/core/actions.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Action definitions, text parsing, and discrete encoding."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from difflib import get_close_matches
|
| 8 |
+
from enum import Enum
|
| 9 |
+
from typing import TYPE_CHECKING
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
from turnabout.i18n import t
|
| 14 |
+
|
| 15 |
+
if TYPE_CHECKING:
|
| 16 |
+
from turnabout.core.schema import CaseData
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ActionType(Enum):
|
| 20 |
+
# Investigation
|
| 21 |
+
MOVE = "move"
|
| 22 |
+
EXAMINE = "examine"
|
| 23 |
+
TALK = "talk"
|
| 24 |
+
PRESENT_TO_NPC = "present_to_npc"
|
| 25 |
+
GO_TO_COURT = "go_to_court"
|
| 26 |
+
# Court
|
| 27 |
+
PRESS = "press"
|
| 28 |
+
PRESENT_EVIDENCE = "present_evidence"
|
| 29 |
+
NEXT_STATEMENT = "next_statement"
|
| 30 |
+
PREV_STATEMENT = "prev_statement"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass(frozen=True)
|
| 34 |
+
class Action:
|
| 35 |
+
action_type: ActionType
|
| 36 |
+
target_id: str | None = None
|
| 37 |
+
secondary_id: str | None = None
|
| 38 |
+
|
| 39 |
+
def __str__(self) -> str:
|
| 40 |
+
t = self.action_type
|
| 41 |
+
if t == ActionType.GO_TO_COURT:
|
| 42 |
+
return "go to court"
|
| 43 |
+
if t == ActionType.NEXT_STATEMENT:
|
| 44 |
+
return "next"
|
| 45 |
+
if t == ActionType.PREV_STATEMENT:
|
| 46 |
+
return "prev"
|
| 47 |
+
if t == ActionType.MOVE:
|
| 48 |
+
return f"move {self.target_id}"
|
| 49 |
+
if t == ActionType.EXAMINE:
|
| 50 |
+
return f"examine {self.target_id}"
|
| 51 |
+
if t == ActionType.TALK:
|
| 52 |
+
return f"talk {self.target_id}"
|
| 53 |
+
if t == ActionType.PRESS:
|
| 54 |
+
return f"press {self.target_id}"
|
| 55 |
+
if t == ActionType.PRESENT_EVIDENCE:
|
| 56 |
+
return f"present {self.target_id}"
|
| 57 |
+
if t == ActionType.PRESENT_TO_NPC:
|
| 58 |
+
return f"present {self.target_id} to {self.secondary_id}"
|
| 59 |
+
return self.action_type.value
|
| 60 |
+
|
| 61 |
+
def display(self, lang: str, case: "CaseData") -> str:
|
| 62 |
+
at = self.action_type
|
| 63 |
+
if at == ActionType.MOVE:
|
| 64 |
+
loc = case.get_location(self.target_id)
|
| 65 |
+
name = loc.name if loc else self.target_id
|
| 66 |
+
return t("action_move", lang).format(name=name)
|
| 67 |
+
if at == ActionType.EXAMINE:
|
| 68 |
+
name = self.target_id
|
| 69 |
+
for loc in case.locations:
|
| 70 |
+
for ex in loc.examinables:
|
| 71 |
+
if ex.id == self.target_id:
|
| 72 |
+
name = ex.name
|
| 73 |
+
break
|
| 74 |
+
return t("action_examine", lang).format(name=name)
|
| 75 |
+
if at == ActionType.TALK:
|
| 76 |
+
ch = case.get_character(self.target_id)
|
| 77 |
+
name = ch.name if ch else self.target_id
|
| 78 |
+
return t("action_talk", lang).format(name=name)
|
| 79 |
+
if at == ActionType.PRESS:
|
| 80 |
+
return t("action_press", lang)
|
| 81 |
+
if at == ActionType.PRESENT_EVIDENCE:
|
| 82 |
+
ev = case.get_evidence(self.target_id)
|
| 83 |
+
name = ev.name if ev else self.target_id
|
| 84 |
+
return t("action_present_evidence", lang).format(name=name)
|
| 85 |
+
if at == ActionType.PRESENT_TO_NPC:
|
| 86 |
+
ev = case.get_evidence(self.target_id)
|
| 87 |
+
ch = case.get_character(self.secondary_id)
|
| 88 |
+
ev_name = ev.name if ev else self.target_id
|
| 89 |
+
ch_name = ch.name if ch else self.secondary_id
|
| 90 |
+
return t("action_present_to_npc", lang).format(ev=ev_name, char=ch_name)
|
| 91 |
+
if at == ActionType.GO_TO_COURT:
|
| 92 |
+
return t("action_go_to_court", lang)
|
| 93 |
+
if at == ActionType.NEXT_STATEMENT:
|
| 94 |
+
return t("action_next", lang)
|
| 95 |
+
if at == ActionType.PREV_STATEMENT:
|
| 96 |
+
return t("action_prev", lang)
|
| 97 |
+
return str(self)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
_PATTERNS: list[tuple[re.Pattern, ActionType, int]] = [
|
| 101 |
+
(re.compile(r"^go\s+to\s+court$", re.I), ActionType.GO_TO_COURT, 0),
|
| 102 |
+
(re.compile(r"^next$", re.I), ActionType.NEXT_STATEMENT, 0),
|
| 103 |
+
(re.compile(r"^prev(?:ious)?$", re.I), ActionType.PREV_STATEMENT, 0),
|
| 104 |
+
(re.compile(r"^move\s+(?:to\s+)?(.+)$", re.I), ActionType.MOVE, 1),
|
| 105 |
+
(re.compile(r"^examine\s+(.+)$", re.I), ActionType.EXAMINE, 1),
|
| 106 |
+
(re.compile(r"^talk\s+(?:to\s+)?(.+)$", re.I), ActionType.TALK, 1),
|
| 107 |
+
(re.compile(r"^press(?:\s+(\S+))?$", re.I), ActionType.PRESS, 1),
|
| 108 |
+
(re.compile(r"^present\s+(\S+)\s+(?:to\s+)?(\S+)$", re.I), ActionType.PRESENT_TO_NPC, 2),
|
| 109 |
+
(re.compile(r"^present\s+(\S+)$", re.I), ActionType.PRESENT_EVIDENCE, 1),
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class ActionParser:
|
| 114 |
+
def __init__(self, case: CaseData):
|
| 115 |
+
self._names: dict[str, str] = {}
|
| 116 |
+
for loc in case.locations:
|
| 117 |
+
self._names[loc.name.lower()] = loc.id
|
| 118 |
+
self._names[loc.id.lower()] = loc.id
|
| 119 |
+
for ex in loc.examinables:
|
| 120 |
+
self._names[ex.name.lower()] = ex.id
|
| 121 |
+
self._names[ex.id.lower()] = ex.id
|
| 122 |
+
for ch in case.characters:
|
| 123 |
+
self._names[ch.name.lower()] = ch.id
|
| 124 |
+
self._names[ch.id.lower()] = ch.id
|
| 125 |
+
for ev in case.evidence:
|
| 126 |
+
self._names[ev.name.lower()] = ev.id
|
| 127 |
+
self._names[ev.id.lower()] = ev.id
|
| 128 |
+
for rnd in case.court.rounds:
|
| 129 |
+
for tst in rnd.testimonies:
|
| 130 |
+
for stmt in tst.statements:
|
| 131 |
+
self._names[stmt.id.lower()] = stmt.id
|
| 132 |
+
|
| 133 |
+
def _resolve(self, raw: str) -> str | None:
|
| 134 |
+
low = raw.strip().lower()
|
| 135 |
+
if low in self._names:
|
| 136 |
+
return self._names[low]
|
| 137 |
+
matches = get_close_matches(low, self._names.keys(), n=1, cutoff=0.6)
|
| 138 |
+
if matches:
|
| 139 |
+
return self._names[matches[0]]
|
| 140 |
+
return raw.strip()
|
| 141 |
+
|
| 142 |
+
def parse(self, text: str) -> Action | None:
|
| 143 |
+
text = text.strip()
|
| 144 |
+
for pattern, action_type, n_groups in _PATTERNS:
|
| 145 |
+
m = pattern.match(text)
|
| 146 |
+
if not m:
|
| 147 |
+
continue
|
| 148 |
+
if n_groups == 0:
|
| 149 |
+
return Action(action_type)
|
| 150 |
+
if n_groups == 1:
|
| 151 |
+
g = m.group(1)
|
| 152 |
+
target = self._resolve(g) if g else None
|
| 153 |
+
return Action(action_type, target_id=target)
|
| 154 |
+
if n_groups == 2:
|
| 155 |
+
return Action(
|
| 156 |
+
action_type,
|
| 157 |
+
target_id=self._resolve(m.group(1)),
|
| 158 |
+
secondary_id=self._resolve(m.group(2)),
|
| 159 |
+
)
|
| 160 |
+
return None
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class DiscreteActionEncoder:
|
| 164 |
+
def __init__(self, case: CaseData):
|
| 165 |
+
self._actions: list[Action] = []
|
| 166 |
+
self._index: dict[Action, int] = {}
|
| 167 |
+
self._build(case)
|
| 168 |
+
|
| 169 |
+
def _build(self, case: CaseData) -> None:
|
| 170 |
+
actions: list[Action] = []
|
| 171 |
+
loc_ids = [loc.id for loc in case.locations]
|
| 172 |
+
for lid in loc_ids:
|
| 173 |
+
actions.append(Action(ActionType.MOVE, target_id=lid))
|
| 174 |
+
|
| 175 |
+
exam_ids: list[str] = []
|
| 176 |
+
for loc in case.locations:
|
| 177 |
+
for ex in loc.examinables:
|
| 178 |
+
if ex.id not in exam_ids:
|
| 179 |
+
exam_ids.append(ex.id)
|
| 180 |
+
for eid in exam_ids:
|
| 181 |
+
actions.append(Action(ActionType.EXAMINE, target_id=eid))
|
| 182 |
+
|
| 183 |
+
char_ids = [c.id for c in case.characters if c.role not in ("judge",)]
|
| 184 |
+
for cid in char_ids:
|
| 185 |
+
actions.append(Action(ActionType.TALK, target_id=cid))
|
| 186 |
+
|
| 187 |
+
ev_ids = [e.id for e in case.evidence]
|
| 188 |
+
for eid in ev_ids:
|
| 189 |
+
for cid in char_ids:
|
| 190 |
+
actions.append(Action(ActionType.PRESENT_TO_NPC, target_id=eid, secondary_id=cid))
|
| 191 |
+
|
| 192 |
+
actions.append(Action(ActionType.GO_TO_COURT))
|
| 193 |
+
|
| 194 |
+
all_stmts: list[str] = []
|
| 195 |
+
for rnd in case.court.rounds:
|
| 196 |
+
for t in rnd.testimonies:
|
| 197 |
+
for s in t.statements:
|
| 198 |
+
if s.id not in all_stmts:
|
| 199 |
+
all_stmts.append(s.id)
|
| 200 |
+
for sid in all_stmts:
|
| 201 |
+
actions.append(Action(ActionType.PRESS, target_id=sid))
|
| 202 |
+
|
| 203 |
+
for eid in ev_ids:
|
| 204 |
+
actions.append(Action(ActionType.PRESENT_EVIDENCE, target_id=eid))
|
| 205 |
+
|
| 206 |
+
actions.append(Action(ActionType.NEXT_STATEMENT))
|
| 207 |
+
actions.append(Action(ActionType.PREV_STATEMENT))
|
| 208 |
+
|
| 209 |
+
self._actions = actions
|
| 210 |
+
self._index = {a: i for i, a in enumerate(actions)}
|
| 211 |
+
|
| 212 |
+
@property
|
| 213 |
+
def n_actions(self) -> int:
|
| 214 |
+
return len(self._actions)
|
| 215 |
+
|
| 216 |
+
def encode(self, action: Action) -> int:
|
| 217 |
+
return self._index[action]
|
| 218 |
+
|
| 219 |
+
def decode(self, action_int: int) -> Action:
|
| 220 |
+
return self._actions[action_int]
|
| 221 |
+
|
| 222 |
+
def get_action_mask(self, valid_actions: list[Action]) -> np.ndarray:
|
| 223 |
+
mask = np.zeros(self.n_actions, dtype=np.int8)
|
| 224 |
+
valid_set = set(valid_actions)
|
| 225 |
+
for i, a in enumerate(self._actions):
|
| 226 |
+
if a in valid_set:
|
| 227 |
+
mask[i] = 1
|
| 228 |
+
return mask
|
turnabout/core/engine.py
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core game engine — state machine driving all game logic."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections import deque
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from typing import Literal
|
| 8 |
+
|
| 9 |
+
from turnabout.core.actions import Action, ActionType
|
| 10 |
+
from turnabout.core.schema import CaseData, Statement, Testimony
|
| 11 |
+
from turnabout.core.state import ActionRecord, GamePhase, GameState
|
| 12 |
+
from turnabout.i18n import t
|
| 13 |
+
|
| 14 |
+
DEFAULT_REWARDS = {
|
| 15 |
+
"correct_contradiction": 1.0,
|
| 16 |
+
"optional_contradiction": 0.3,
|
| 17 |
+
"wrong_presentation": -0.5,
|
| 18 |
+
"evidence_collected": 0.1,
|
| 19 |
+
"press_useful": 0.05,
|
| 20 |
+
"press_redundant": 0.0,
|
| 21 |
+
"win": 5.0,
|
| 22 |
+
"lose": -5.0,
|
| 23 |
+
"step_cost": -0.01,
|
| 24 |
+
"invalid_action": -0.1,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class StepResult:
|
| 30 |
+
observation: str
|
| 31 |
+
reward: float
|
| 32 |
+
done: bool
|
| 33 |
+
info: dict
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class TurnaboutEngine:
|
| 37 |
+
def __init__(
|
| 38 |
+
self,
|
| 39 |
+
case: CaseData,
|
| 40 |
+
difficulty: Literal["easy", "hard"] | None = None,
|
| 41 |
+
rewards: dict[str, float] | None = None,
|
| 42 |
+
lang: str = "en",
|
| 43 |
+
):
|
| 44 |
+
self.case = case
|
| 45 |
+
self.difficulty = difficulty or case.difficulty
|
| 46 |
+
self.rewards = {**DEFAULT_REWARDS, **(rewards or {})}
|
| 47 |
+
self.lang = lang
|
| 48 |
+
self.state: GameState | None = None
|
| 49 |
+
|
| 50 |
+
def reset(self) -> tuple[GameState, str]:
|
| 51 |
+
if self.difficulty == "easy":
|
| 52 |
+
inventory = {e.id for e in self.case.evidence}
|
| 53 |
+
phase = GamePhase.COURT_TESTIMONY
|
| 54 |
+
location = None
|
| 55 |
+
else:
|
| 56 |
+
inventory = {e.id for e in self.case.get_pre_given_evidence()}
|
| 57 |
+
phase = GamePhase.INVESTIGATION
|
| 58 |
+
start_locs = [loc for loc in self.case.locations if loc.available_from_start]
|
| 59 |
+
location = start_locs[0].id if start_locs else None
|
| 60 |
+
|
| 61 |
+
rnd = self.case.court.rounds[0]
|
| 62 |
+
testimony_id = rnd.initial_testimony_id
|
| 63 |
+
|
| 64 |
+
self.state = GameState(
|
| 65 |
+
phase=phase,
|
| 66 |
+
current_location_id=location,
|
| 67 |
+
inventory=inventory,
|
| 68 |
+
unlocked_locations={loc.id for loc in self.case.locations if loc.available_from_start},
|
| 69 |
+
current_round_index=0,
|
| 70 |
+
current_testimony_id=testimony_id,
|
| 71 |
+
current_statement_index=0,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
obs = self._make_observation("", phase_entry=True)
|
| 75 |
+
return self.state, obs
|
| 76 |
+
|
| 77 |
+
def step(self, action: Action) -> tuple[GameState, str, float, bool, dict]:
|
| 78 |
+
assert self.state is not None, "Must call reset() first"
|
| 79 |
+
assert not self.state.is_terminal, "Game is already over"
|
| 80 |
+
|
| 81 |
+
self.state.step_count += 1
|
| 82 |
+
reward = self.rewards["step_cost"]
|
| 83 |
+
|
| 84 |
+
if action.action_type == ActionType.PRESS and action.target_id is None:
|
| 85 |
+
testimony = self._current_testimony()
|
| 86 |
+
visible = self._visible_statements(testimony)
|
| 87 |
+
idx = self.state.current_statement_index
|
| 88 |
+
if idx < len(visible):
|
| 89 |
+
action = Action(ActionType.PRESS, target_id=visible[idx].id)
|
| 90 |
+
|
| 91 |
+
valid = self.get_valid_actions()
|
| 92 |
+
if action not in valid:
|
| 93 |
+
obs = t("invalid_action", self.lang).format(action=action, valid=', '.join(str(a) for a in valid))
|
| 94 |
+
reward += self.rewards["invalid_action"]
|
| 95 |
+
self._record(action, obs)
|
| 96 |
+
return self.state, obs, reward, False, {"valid": False}
|
| 97 |
+
|
| 98 |
+
if self.state.phase == GamePhase.INVESTIGATION:
|
| 99 |
+
result = self._handle_investigation(action)
|
| 100 |
+
elif self.state.phase in (GamePhase.COURT_TESTIMONY, GamePhase.COURT_CROSS_EXAM):
|
| 101 |
+
result = self._handle_court(action)
|
| 102 |
+
else:
|
| 103 |
+
result = StepResult("Unexpected phase.", 0, False, {})
|
| 104 |
+
|
| 105 |
+
reward += result.reward
|
| 106 |
+
self._record(action, result.observation)
|
| 107 |
+
return self.state, result.observation, reward, result.done, result.info
|
| 108 |
+
|
| 109 |
+
def get_valid_actions(self) -> list[Action]:
|
| 110 |
+
if self.state is None or self.state.is_terminal:
|
| 111 |
+
return []
|
| 112 |
+
|
| 113 |
+
actions: list[Action] = []
|
| 114 |
+
|
| 115 |
+
if self.state.phase == GamePhase.INVESTIGATION:
|
| 116 |
+
self._add_investigation_actions(actions)
|
| 117 |
+
elif self.state.phase in (GamePhase.COURT_TESTIMONY, GamePhase.COURT_CROSS_EXAM):
|
| 118 |
+
self._add_court_actions(actions)
|
| 119 |
+
|
| 120 |
+
return actions
|
| 121 |
+
|
| 122 |
+
def _add_investigation_actions(self, actions: list[Action]) -> None:
|
| 123 |
+
loc = self.case.get_location(self.state.current_location_id)
|
| 124 |
+
if not loc:
|
| 125 |
+
return
|
| 126 |
+
|
| 127 |
+
for cid in loc.connected_to:
|
| 128 |
+
target_loc = self.case.get_location(cid)
|
| 129 |
+
if target_loc and cid in self.state.unlocked_locations:
|
| 130 |
+
actions.append(Action(ActionType.MOVE, target_id=cid))
|
| 131 |
+
|
| 132 |
+
for ex in loc.examinables:
|
| 133 |
+
actions.append(Action(ActionType.EXAMINE, target_id=ex.id))
|
| 134 |
+
|
| 135 |
+
for cid in loc.characters_present:
|
| 136 |
+
ch = self.case.get_character(cid)
|
| 137 |
+
if ch and ch.role != "judge":
|
| 138 |
+
actions.append(Action(ActionType.TALK, target_id=cid))
|
| 139 |
+
|
| 140 |
+
for eid in sorted(self.state.inventory):
|
| 141 |
+
for cid in loc.characters_present:
|
| 142 |
+
ch = self.case.get_character(cid)
|
| 143 |
+
if ch and ch.role not in ("judge", "defendant"):
|
| 144 |
+
actions.append(Action(ActionType.PRESENT_TO_NPC, target_id=eid, secondary_id=cid))
|
| 145 |
+
|
| 146 |
+
if self._can_go_to_court():
|
| 147 |
+
actions.append(Action(ActionType.GO_TO_COURT))
|
| 148 |
+
|
| 149 |
+
def _add_court_actions(self, actions: list[Action]) -> None:
|
| 150 |
+
testimony = self._current_testimony()
|
| 151 |
+
if not testimony:
|
| 152 |
+
return
|
| 153 |
+
|
| 154 |
+
visible = self._visible_statements(testimony)
|
| 155 |
+
if not visible:
|
| 156 |
+
return
|
| 157 |
+
|
| 158 |
+
if self.state.phase == GamePhase.COURT_TESTIMONY:
|
| 159 |
+
actions.append(Action(ActionType.NEXT_STATEMENT))
|
| 160 |
+
return
|
| 161 |
+
|
| 162 |
+
idx = self.state.current_statement_index
|
| 163 |
+
stmt = visible[idx] if idx < len(visible) else None
|
| 164 |
+
if stmt:
|
| 165 |
+
actions.append(Action(ActionType.PRESS, target_id=stmt.id))
|
| 166 |
+
|
| 167 |
+
for eid in sorted(self.state.inventory):
|
| 168 |
+
actions.append(Action(ActionType.PRESENT_EVIDENCE, target_id=eid))
|
| 169 |
+
|
| 170 |
+
if idx < len(visible) - 1:
|
| 171 |
+
actions.append(Action(ActionType.NEXT_STATEMENT))
|
| 172 |
+
if idx > 0:
|
| 173 |
+
actions.append(Action(ActionType.PREV_STATEMENT))
|
| 174 |
+
|
| 175 |
+
def _handle_investigation(self, action: Action) -> StepResult:
|
| 176 |
+
if action.action_type == ActionType.MOVE:
|
| 177 |
+
return self._do_move(action.target_id)
|
| 178 |
+
elif action.action_type == ActionType.EXAMINE:
|
| 179 |
+
return self._do_examine(action.target_id)
|
| 180 |
+
elif action.action_type == ActionType.TALK:
|
| 181 |
+
return self._do_talk(action.target_id)
|
| 182 |
+
elif action.action_type == ActionType.PRESENT_TO_NPC:
|
| 183 |
+
return self._do_present_to_npc(action.target_id, action.secondary_id)
|
| 184 |
+
elif action.action_type == ActionType.GO_TO_COURT:
|
| 185 |
+
return self._do_go_to_court()
|
| 186 |
+
return StepResult(t("unknown_action", self.lang), 0, False, {})
|
| 187 |
+
|
| 188 |
+
def _handle_court(self, action: Action) -> StepResult:
|
| 189 |
+
if self.state.phase == GamePhase.COURT_TESTIMONY:
|
| 190 |
+
if action.action_type == ActionType.NEXT_STATEMENT:
|
| 191 |
+
return self._do_advance_testimony()
|
| 192 |
+
return StepResult(t("during_testimony", self.lang), 0, False, {})
|
| 193 |
+
|
| 194 |
+
if action.action_type == ActionType.PRESS:
|
| 195 |
+
return self._do_press(action.target_id)
|
| 196 |
+
elif action.action_type == ActionType.PRESENT_EVIDENCE:
|
| 197 |
+
return self._do_present_evidence(action.target_id)
|
| 198 |
+
elif action.action_type == ActionType.NEXT_STATEMENT:
|
| 199 |
+
return self._do_next_statement()
|
| 200 |
+
elif action.action_type == ActionType.PREV_STATEMENT:
|
| 201 |
+
return self._do_prev_statement()
|
| 202 |
+
return StepResult(t("unknown_action", self.lang), 0, False, {})
|
| 203 |
+
|
| 204 |
+
# --- Investigation actions ---
|
| 205 |
+
|
| 206 |
+
def _do_move(self, location_id: str) -> StepResult:
|
| 207 |
+
self.state.current_location_id = location_id
|
| 208 |
+
loc = self.case.get_location(location_id)
|
| 209 |
+
obs = t("move_to", self.lang).format(name=loc.name, desc=loc.description)
|
| 210 |
+
return StepResult(obs, 0, False, {"action": "move", "location": location_id})
|
| 211 |
+
|
| 212 |
+
def _do_examine(self, target_id: str) -> StepResult:
|
| 213 |
+
loc = self.case.get_location(self.state.current_location_id)
|
| 214 |
+
ex = next((e for e in loc.examinables if e.id == target_id), None)
|
| 215 |
+
if not ex:
|
| 216 |
+
return StepResult(t("nothing_to_examine", self.lang), 0, False, {})
|
| 217 |
+
|
| 218 |
+
already = target_id in self.state.examined
|
| 219 |
+
if already and ex.examined_description:
|
| 220 |
+
obs = ex.examined_description
|
| 221 |
+
else:
|
| 222 |
+
obs = ex.description
|
| 223 |
+
self.state.examined.add(target_id)
|
| 224 |
+
|
| 225 |
+
reward = 0.0
|
| 226 |
+
new_evidence = self._check_evidence_from_examine(target_id)
|
| 227 |
+
if new_evidence:
|
| 228 |
+
reward = self.rewards["evidence_collected"]
|
| 229 |
+
obs += t("new_evidence", self.lang).format(name=new_evidence.name)
|
| 230 |
+
|
| 231 |
+
return StepResult(obs, reward, False, {"action": "examine", "target": target_id})
|
| 232 |
+
|
| 233 |
+
def _do_talk(self, character_id: str) -> StepResult:
|
| 234 |
+
dialogue = next((d for d in self.case.dialogues if d.character_id == character_id), None)
|
| 235 |
+
ch = self.case.get_character(character_id)
|
| 236 |
+
if not dialogue:
|
| 237 |
+
return StepResult(t("nothing_to_say", self.lang).format(name=ch.name), 0, False, {})
|
| 238 |
+
|
| 239 |
+
visible_lines = [
|
| 240 |
+
line for line in dialogue.lines
|
| 241 |
+
if all(p in self.state.inventory for p in line.prerequisites)
|
| 242 |
+
and line.id not in self.state.dialogue_lines_seen
|
| 243 |
+
]
|
| 244 |
+
|
| 245 |
+
if not visible_lines:
|
| 246 |
+
already_seen = [
|
| 247 |
+
line for line in dialogue.lines
|
| 248 |
+
if all(p in self.state.inventory for p in line.prerequisites)
|
| 249 |
+
]
|
| 250 |
+
if already_seen:
|
| 251 |
+
obs = f"{ch.name}: \"{already_seen[-1].text}\"\n{t('nothing_new', self.lang)}"
|
| 252 |
+
else:
|
| 253 |
+
obs = t("nothing_to_say_now", self.lang).format(name=ch.name)
|
| 254 |
+
return StepResult(obs, 0, False, {"action": "talk"})
|
| 255 |
+
|
| 256 |
+
reward = 0.0
|
| 257 |
+
parts = []
|
| 258 |
+
for line in visible_lines:
|
| 259 |
+
self.state.dialogue_lines_seen.add(line.id)
|
| 260 |
+
parts.append(f'{ch.name}: "{line.text}"')
|
| 261 |
+
if line.grants_evidence:
|
| 262 |
+
ev = self.case.get_evidence(line.grants_evidence)
|
| 263 |
+
if ev and line.grants_evidence not in self.state.inventory:
|
| 264 |
+
self.state.inventory.add(line.grants_evidence)
|
| 265 |
+
reward += self.rewards["evidence_collected"]
|
| 266 |
+
parts.append(t("new_evidence", self.lang).format(name=ev.name))
|
| 267 |
+
|
| 268 |
+
obs = "\n".join(parts)
|
| 269 |
+
return StepResult(obs, reward, False, {"action": "talk", "character": character_id})
|
| 270 |
+
|
| 271 |
+
def _do_present_to_npc(self, evidence_id: str, character_id: str) -> StepResult:
|
| 272 |
+
pair = (evidence_id, character_id)
|
| 273 |
+
self.state.evidence_presented_to.add(pair)
|
| 274 |
+
|
| 275 |
+
dialogue = next((d for d in self.case.dialogues if d.character_id == character_id), None)
|
| 276 |
+
ch = self.case.get_character(character_id)
|
| 277 |
+
ev = self.case.get_evidence(evidence_id)
|
| 278 |
+
if not dialogue:
|
| 279 |
+
return StepResult(t("no_comment", self.lang).format(char=ch.name, ev=ev.name), 0, False, {})
|
| 280 |
+
|
| 281 |
+
response = next(
|
| 282 |
+
(r for r in dialogue.present_responses if r.evidence_id == evidence_id), None
|
| 283 |
+
)
|
| 284 |
+
if not response:
|
| 285 |
+
return StepResult(
|
| 286 |
+
t("nothing_useful", self.lang).format(char=ch.name, ev=ev.name),
|
| 287 |
+
0, False, {"action": "present_to_npc"},
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
reward = 0.0
|
| 291 |
+
obs = f'{ch.name}: "{response.text}"'
|
| 292 |
+
if response.grants_evidence and response.grants_evidence not in self.state.inventory:
|
| 293 |
+
new_ev = self.case.get_evidence(response.grants_evidence)
|
| 294 |
+
self.state.inventory.add(response.grants_evidence)
|
| 295 |
+
reward += self.rewards["evidence_collected"]
|
| 296 |
+
obs += t("new_evidence", self.lang).format(name=new_ev.name)
|
| 297 |
+
self._check_unlock_locations()
|
| 298 |
+
|
| 299 |
+
if response.unlocks_dialogue_line:
|
| 300 |
+
pass
|
| 301 |
+
|
| 302 |
+
ev_from_acquisition = self._check_evidence_from_present(evidence_id, character_id)
|
| 303 |
+
if ev_from_acquisition and ev_from_acquisition.id not in self.state.inventory:
|
| 304 |
+
self.state.inventory.add(ev_from_acquisition.id)
|
| 305 |
+
reward += self.rewards["evidence_collected"]
|
| 306 |
+
obs += t("new_evidence", self.lang).format(name=ev_from_acquisition.name)
|
| 307 |
+
self._check_unlock_locations()
|
| 308 |
+
|
| 309 |
+
return StepResult(obs, reward, False, {"action": "present_to_npc"})
|
| 310 |
+
|
| 311 |
+
def _do_go_to_court(self) -> StepResult:
|
| 312 |
+
self.state.phase = GamePhase.COURT_TESTIMONY
|
| 313 |
+
self.state.current_round_index = 0
|
| 314 |
+
rnd = self.case.court.rounds[0]
|
| 315 |
+
self.state.current_testimony_id = rnd.initial_testimony_id
|
| 316 |
+
self.state.current_statement_index = 0
|
| 317 |
+
|
| 318 |
+
testimony = self._current_testimony()
|
| 319 |
+
obs = t("court_in_session", self.lang) + "\n\n"
|
| 320 |
+
if testimony:
|
| 321 |
+
witness = self.case.get_character(testimony.witness_id)
|
| 322 |
+
obs += t("witness_label", self.lang).format(name=witness.name) + "\n"
|
| 323 |
+
obs += t("testimony_label", self.lang).format(title=testimony.title) + "\n\n"
|
| 324 |
+
obs += testimony.preamble
|
| 325 |
+
return StepResult(obs, 0, False, {"action": "go_to_court", "phase": "court_testimony"})
|
| 326 |
+
|
| 327 |
+
# --- Court actions ---
|
| 328 |
+
|
| 329 |
+
def _do_advance_testimony(self) -> StepResult:
|
| 330 |
+
self.state.phase = GamePhase.COURT_CROSS_EXAM
|
| 331 |
+
self.state.current_statement_index = 0
|
| 332 |
+
testimony = self._current_testimony()
|
| 333 |
+
visible = self._visible_statements(testimony)
|
| 334 |
+
|
| 335 |
+
obs = t("cross_exam_header", self.lang) + "\n"
|
| 336 |
+
if testimony:
|
| 337 |
+
obs += f'"{testimony.title}"\n\n'
|
| 338 |
+
if visible:
|
| 339 |
+
obs += t("statement_label", self.lang).format(num=1, text=visible[0].text) + "\n"
|
| 340 |
+
obs += t("press_or_present", self.lang)
|
| 341 |
+
return StepResult(obs, 0, False, {"phase": "cross_exam"})
|
| 342 |
+
|
| 343 |
+
def _do_press(self, statement_id: str | None) -> StepResult:
|
| 344 |
+
testimony = self._current_testimony()
|
| 345 |
+
visible = self._visible_statements(testimony)
|
| 346 |
+
if not statement_id:
|
| 347 |
+
idx = self.state.current_statement_index
|
| 348 |
+
if idx < len(visible):
|
| 349 |
+
stmt = visible[idx]
|
| 350 |
+
statement_id = stmt.id
|
| 351 |
+
else:
|
| 352 |
+
return StepResult(t("no_current_statement", self.lang), 0, False, {})
|
| 353 |
+
else:
|
| 354 |
+
stmt = next((s for s in visible if s.id == statement_id), None)
|
| 355 |
+
if not stmt:
|
| 356 |
+
return StepResult(t("statement_not_visible", self.lang), 0, False, {})
|
| 357 |
+
|
| 358 |
+
self.state.statements_pressed.add(statement_id)
|
| 359 |
+
reward = 0.0
|
| 360 |
+
obs = t("hold_it", self.lang) + f"\n\n{stmt.press_response}"
|
| 361 |
+
|
| 362 |
+
if stmt.press_reveals_statement:
|
| 363 |
+
self.state.revealed_statements.add(stmt.press_reveals_statement)
|
| 364 |
+
new_stmt = self.case.get_statement(stmt.press_reveals_statement)
|
| 365 |
+
if new_stmt:
|
| 366 |
+
obs += t("witness_amends", self.lang).format(text=new_stmt.text)
|
| 367 |
+
reward = self.rewards["press_useful"]
|
| 368 |
+
|
| 369 |
+
if stmt.press_grants_evidence and stmt.press_grants_evidence not in self.state.inventory:
|
| 370 |
+
ev = self.case.get_evidence(stmt.press_grants_evidence)
|
| 371 |
+
self.state.inventory.add(stmt.press_grants_evidence)
|
| 372 |
+
reward = self.rewards["press_useful"]
|
| 373 |
+
obs += t("new_evidence", self.lang).format(name=ev.name)
|
| 374 |
+
|
| 375 |
+
if reward == 0:
|
| 376 |
+
reward = self.rewards["press_redundant"]
|
| 377 |
+
|
| 378 |
+
return StepResult(obs, reward, False, {"action": "press", "statement": statement_id})
|
| 379 |
+
|
| 380 |
+
def _do_present_evidence(self, evidence_id: str) -> StepResult:
|
| 381 |
+
testimony = self._current_testimony()
|
| 382 |
+
visible = self._visible_statements(testimony)
|
| 383 |
+
idx = self.state.current_statement_index
|
| 384 |
+
if idx >= len(visible):
|
| 385 |
+
return StepResult(t("no_current_statement", self.lang), 0, False, {})
|
| 386 |
+
|
| 387 |
+
stmt = visible[idx]
|
| 388 |
+
|
| 389 |
+
if stmt.contradiction and stmt.contradiction.evidence_id == evidence_id:
|
| 390 |
+
return self._correct_contradiction(stmt)
|
| 391 |
+
else:
|
| 392 |
+
return self._wrong_presentation(evidence_id, stmt)
|
| 393 |
+
|
| 394 |
+
def _correct_contradiction(self, stmt: Statement) -> StepResult:
|
| 395 |
+
c = stmt.contradiction
|
| 396 |
+
self.state.contradictions_found.add(stmt.id)
|
| 397 |
+
|
| 398 |
+
reward = (
|
| 399 |
+
self.rewards["correct_contradiction"]
|
| 400 |
+
if c.is_primary
|
| 401 |
+
else self.rewards["optional_contradiction"]
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
obs = t("objection", self.lang) + f"\n\n{c.explanation}"
|
| 405 |
+
|
| 406 |
+
if c.triggers_testimony:
|
| 407 |
+
self.state.current_testimony_id = c.triggers_testimony
|
| 408 |
+
self.state.current_statement_index = 0
|
| 409 |
+
self.state.phase = GamePhase.COURT_TESTIMONY
|
| 410 |
+
new_testimony = self._current_testimony()
|
| 411 |
+
if new_testimony:
|
| 412 |
+
witness = self.case.get_character(new_testimony.witness_id)
|
| 413 |
+
obs += t("new_testimony", self.lang) + "\n"
|
| 414 |
+
obs += t("witness_label", self.lang).format(name=witness.name) + "\n"
|
| 415 |
+
obs += f'"{new_testimony.title}"\n\n'
|
| 416 |
+
obs += new_testimony.preamble
|
| 417 |
+
return StepResult(obs, reward, False, {"action": "objection", "triggers": c.triggers_testimony})
|
| 418 |
+
|
| 419 |
+
if self._check_win():
|
| 420 |
+
self.state.phase = GamePhase.VERDICT_WIN
|
| 421 |
+
obs += t("not_guilty", self.lang)
|
| 422 |
+
reward += self.rewards["win"]
|
| 423 |
+
return StepResult(obs, reward, True, {"action": "objection", "won": True})
|
| 424 |
+
|
| 425 |
+
return StepResult(obs, reward, False, {"action": "objection"})
|
| 426 |
+
|
| 427 |
+
def _wrong_presentation(self, evidence_id: str, stmt: Statement) -> StepResult:
|
| 428 |
+
self.state.penalties += 1
|
| 429 |
+
ev = self.case.get_evidence(evidence_id)
|
| 430 |
+
|
| 431 |
+
obs = t("wrong_present", self.lang).format(
|
| 432 |
+
ev=ev.name, cur=self.state.penalties, max=self.case.court.penalty_limit
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
reward = self.rewards["wrong_presentation"]
|
| 436 |
+
|
| 437 |
+
if self.state.penalties >= self.case.court.penalty_limit:
|
| 438 |
+
self.state.phase = GamePhase.VERDICT_LOSE
|
| 439 |
+
obs += t("guilty", self.lang)
|
| 440 |
+
reward += self.rewards["lose"]
|
| 441 |
+
return StepResult(obs, reward, True, {"action": "wrong_present", "lost": True})
|
| 442 |
+
|
| 443 |
+
return StepResult(obs, reward, False, {"action": "wrong_present"})
|
| 444 |
+
|
| 445 |
+
def _do_next_statement(self) -> StepResult:
|
| 446 |
+
testimony = self._current_testimony()
|
| 447 |
+
visible = self._visible_statements(testimony)
|
| 448 |
+
if self.state.current_statement_index < len(visible) - 1:
|
| 449 |
+
self.state.current_statement_index += 1
|
| 450 |
+
idx = self.state.current_statement_index
|
| 451 |
+
stmt = visible[idx]
|
| 452 |
+
obs = t("statement_label", self.lang).format(num=idx + 1, text=stmt.text)
|
| 453 |
+
return StepResult(obs, 0, False, {"action": "next", "index": idx})
|
| 454 |
+
|
| 455 |
+
def _do_prev_statement(self) -> StepResult:
|
| 456 |
+
if self.state.current_statement_index > 0:
|
| 457 |
+
self.state.current_statement_index -= 1
|
| 458 |
+
idx = self.state.current_statement_index
|
| 459 |
+
testimony = self._current_testimony()
|
| 460 |
+
visible = self._visible_statements(testimony)
|
| 461 |
+
stmt = visible[idx]
|
| 462 |
+
obs = t("statement_label", self.lang).format(num=idx + 1, text=stmt.text)
|
| 463 |
+
return StepResult(obs, 0, False, {"action": "prev", "index": idx})
|
| 464 |
+
|
| 465 |
+
# --- Helpers ---
|
| 466 |
+
|
| 467 |
+
def _current_testimony(self) -> Testimony | None:
|
| 468 |
+
return self.case.get_testimony(self.state.current_testimony_id)
|
| 469 |
+
|
| 470 |
+
def _visible_statements(self, testimony: Testimony | None) -> list[Statement]:
|
| 471 |
+
if not testimony:
|
| 472 |
+
return []
|
| 473 |
+
visible = []
|
| 474 |
+
for s in testimony.statements:
|
| 475 |
+
if s.press_reveals_statement and s.id not in self.state.revealed_statements:
|
| 476 |
+
if any(
|
| 477 |
+
prev.press_reveals_statement == s.id
|
| 478 |
+
for prev in testimony.statements
|
| 479 |
+
if prev.id != s.id
|
| 480 |
+
):
|
| 481 |
+
continue
|
| 482 |
+
visible.append(s)
|
| 483 |
+
return visible
|
| 484 |
+
|
| 485 |
+
def _can_go_to_court(self) -> bool:
|
| 486 |
+
if self.difficulty == "easy":
|
| 487 |
+
return False
|
| 488 |
+
gate = self.case.investigation_gate
|
| 489 |
+
if not gate:
|
| 490 |
+
return True
|
| 491 |
+
if gate.required_evidence:
|
| 492 |
+
return all(eid in self.state.inventory for eid in gate.required_evidence)
|
| 493 |
+
return True
|
| 494 |
+
|
| 495 |
+
def _check_win(self) -> bool:
|
| 496 |
+
required = set(self.case.court.win_condition.required_contradiction_ids)
|
| 497 |
+
return required.issubset(self.state.contradictions_found)
|
| 498 |
+
|
| 499 |
+
def _check_evidence_from_examine(self, target_id: str) -> "EvidenceItem | None":
|
| 500 |
+
for ev in self.case.evidence:
|
| 501 |
+
if (
|
| 502 |
+
ev.source == "investigation"
|
| 503 |
+
and ev.acquisition
|
| 504 |
+
and ev.acquisition.method == "examine"
|
| 505 |
+
and ev.acquisition.target_id == target_id
|
| 506 |
+
and ev.id not in self.state.inventory
|
| 507 |
+
and all(p in self.state.inventory for p in ev.acquisition.prerequisites)
|
| 508 |
+
):
|
| 509 |
+
self.state.inventory.add(ev.id)
|
| 510 |
+
self._check_unlock_locations()
|
| 511 |
+
return ev
|
| 512 |
+
return None
|
| 513 |
+
|
| 514 |
+
def _check_evidence_from_present(self, evidence_id: str, character_id: str) -> "EvidenceItem | None":
|
| 515 |
+
for ev in self.case.evidence:
|
| 516 |
+
if (
|
| 517 |
+
ev.source == "investigation"
|
| 518 |
+
and ev.acquisition
|
| 519 |
+
and ev.acquisition.method == "present"
|
| 520 |
+
and ev.acquisition.target_id == character_id
|
| 521 |
+
and ev.acquisition.present_evidence_id == evidence_id
|
| 522 |
+
and ev.id not in self.state.inventory
|
| 523 |
+
and all(p in self.state.inventory for p in ev.acquisition.prerequisites)
|
| 524 |
+
):
|
| 525 |
+
return ev
|
| 526 |
+
return None
|
| 527 |
+
|
| 528 |
+
def _check_unlock_locations(self) -> None:
|
| 529 |
+
for loc in self.case.locations:
|
| 530 |
+
if (
|
| 531 |
+
not loc.available_from_start
|
| 532 |
+
and loc.id not in self.state.unlocked_locations
|
| 533 |
+
and all(p in self.state.inventory for p in loc.unlock_prerequisites)
|
| 534 |
+
):
|
| 535 |
+
self.state.unlocked_locations.add(loc.id)
|
| 536 |
+
|
| 537 |
+
def _record(self, action: Action, obs: str) -> None:
|
| 538 |
+
self.state.action_history.append(
|
| 539 |
+
ActionRecord(
|
| 540 |
+
step=self.state.step_count,
|
| 541 |
+
phase=self.state.phase,
|
| 542 |
+
action_type=action.action_type.value,
|
| 543 |
+
target_id=action.target_id,
|
| 544 |
+
secondary_id=action.secondary_id,
|
| 545 |
+
result=obs[:200],
|
| 546 |
+
)
|
| 547 |
+
)
|
| 548 |
+
|
| 549 |
+
def _make_observation(self, text: str, phase_entry: bool = False) -> str:
|
| 550 |
+
if not phase_entry:
|
| 551 |
+
return text
|
| 552 |
+
|
| 553 |
+
if self.state.phase == GamePhase.INVESTIGATION:
|
| 554 |
+
loc = self.case.get_location(self.state.current_location_id)
|
| 555 |
+
if loc:
|
| 556 |
+
return t("investigation_header", self.lang) + "\n" + t("move_to", self.lang).format(name=loc.name, desc=loc.description)
|
| 557 |
+
return t("investigation_header", self.lang)
|
| 558 |
+
elif self.state.phase == GamePhase.COURT_TESTIMONY:
|
| 559 |
+
testimony = self._current_testimony()
|
| 560 |
+
if testimony:
|
| 561 |
+
witness = self.case.get_character(testimony.witness_id)
|
| 562 |
+
return (
|
| 563 |
+
t("court_in_session", self.lang) + "\n\n"
|
| 564 |
+
+ t("witness_label", self.lang).format(name=witness.name) + "\n"
|
| 565 |
+
+ t("testimony_label", self.lang).format(title=testimony.title) + "\n\n"
|
| 566 |
+
+ testimony.preamble
|
| 567 |
+
)
|
| 568 |
+
return t("court_in_session", self.lang)
|
| 569 |
+
return text
|
| 570 |
+
|
| 571 |
+
def get_optimal_path(self) -> list[Action] | None:
|
| 572 |
+
"""BFS to find shortest winning action sequence."""
|
| 573 |
+
if self.state is None:
|
| 574 |
+
return None
|
| 575 |
+
|
| 576 |
+
initial = self.state.copy()
|
| 577 |
+
queue: deque[tuple[GameState, list[Action]]] = deque()
|
| 578 |
+
queue.append((initial, []))
|
| 579 |
+
visited: set[str] = set()
|
| 580 |
+
|
| 581 |
+
while queue:
|
| 582 |
+
state, path = queue.popleft()
|
| 583 |
+
key = self._state_key(state)
|
| 584 |
+
if key in visited:
|
| 585 |
+
continue
|
| 586 |
+
visited.add(key)
|
| 587 |
+
|
| 588 |
+
if state.phase == GamePhase.VERDICT_WIN:
|
| 589 |
+
return path
|
| 590 |
+
|
| 591 |
+
if len(visited) > 50000:
|
| 592 |
+
return None
|
| 593 |
+
|
| 594 |
+
saved = self.state
|
| 595 |
+
self.state = state
|
| 596 |
+
valid_actions = self.get_valid_actions()
|
| 597 |
+
self.state = saved
|
| 598 |
+
|
| 599 |
+
for action in valid_actions:
|
| 600 |
+
child_engine = TurnaboutEngine(self.case, self.difficulty, self.rewards)
|
| 601 |
+
child_engine.state = state.copy()
|
| 602 |
+
_, _, _, done, _ = child_engine.step(action)
|
| 603 |
+
new_state = child_engine.state
|
| 604 |
+
new_key = self._state_key(new_state)
|
| 605 |
+
if new_key not in visited:
|
| 606 |
+
queue.append((new_state, path + [action]))
|
| 607 |
+
|
| 608 |
+
return None
|
| 609 |
+
|
| 610 |
+
@staticmethod
|
| 611 |
+
def _state_key(state: GameState) -> str:
|
| 612 |
+
return (
|
| 613 |
+
f"{state.phase.value}|{state.current_location_id}|"
|
| 614 |
+
f"{sorted(state.inventory)}|{state.current_testimony_id}|"
|
| 615 |
+
f"{state.current_statement_index}|{state.penalties}|"
|
| 616 |
+
f"{sorted(state.contradictions_found)}|{sorted(state.revealed_statements)}"
|
| 617 |
+
)
|
turnabout/core/schema.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic v2 models defining the case data schema.
|
| 2 |
+
|
| 3 |
+
This is the most critical file in the project. All other components
|
| 4 |
+
consume or produce this schema.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import Literal
|
| 10 |
+
|
| 11 |
+
from pydantic import BaseModel, field_validator
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class CaseMetadata(BaseModel):
|
| 15 |
+
author: str = "human"
|
| 16 |
+
version: str = "1.0"
|
| 17 |
+
tags: list[str] = []
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class Character(BaseModel):
|
| 21 |
+
id: str
|
| 22 |
+
name: str
|
| 23 |
+
description: str
|
| 24 |
+
role: Literal["defendant", "witness", "npc", "prosecutor", "judge"]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class EvidenceAcquisition(BaseModel):
|
| 28 |
+
method: Literal["examine", "talk", "present"]
|
| 29 |
+
location_id: str
|
| 30 |
+
target_id: str
|
| 31 |
+
prerequisites: list[str] = []
|
| 32 |
+
present_evidence_id: str | None = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class EvidenceItem(BaseModel):
|
| 36 |
+
id: str
|
| 37 |
+
name: str
|
| 38 |
+
item_type: Literal["document", "physical", "photo", "testimony_record"]
|
| 39 |
+
description: str
|
| 40 |
+
detail: str
|
| 41 |
+
source: Literal["pre_given", "investigation"]
|
| 42 |
+
acquisition: EvidenceAcquisition | None = None
|
| 43 |
+
|
| 44 |
+
@field_validator("acquisition")
|
| 45 |
+
@classmethod
|
| 46 |
+
def check_acquisition(cls, v: EvidenceAcquisition | None, info) -> EvidenceAcquisition | None:
|
| 47 |
+
source = info.data.get("source")
|
| 48 |
+
if source == "investigation" and v is None:
|
| 49 |
+
raise ValueError("investigation evidence must have acquisition info")
|
| 50 |
+
return v
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class Examinable(BaseModel):
|
| 54 |
+
id: str
|
| 55 |
+
name: str
|
| 56 |
+
description: str
|
| 57 |
+
examined_description: str | None = None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class Location(BaseModel):
|
| 61 |
+
id: str
|
| 62 |
+
name: str
|
| 63 |
+
description: str
|
| 64 |
+
connected_to: list[str] = []
|
| 65 |
+
examinables: list[Examinable] = []
|
| 66 |
+
characters_present: list[str] = []
|
| 67 |
+
available_from_start: bool = True
|
| 68 |
+
unlock_prerequisites: list[str] = []
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class DialogueLine(BaseModel):
|
| 72 |
+
id: str
|
| 73 |
+
text: str
|
| 74 |
+
prerequisites: list[str] = []
|
| 75 |
+
grants_evidence: str | None = None
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class EvidencePresentResponse(BaseModel):
|
| 79 |
+
evidence_id: str
|
| 80 |
+
text: str
|
| 81 |
+
grants_evidence: str | None = None
|
| 82 |
+
unlocks_dialogue_line: str | None = None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class DialogueTree(BaseModel):
|
| 86 |
+
character_id: str
|
| 87 |
+
lines: list[DialogueLine] = []
|
| 88 |
+
present_responses: list[EvidencePresentResponse] = []
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class Contradiction(BaseModel):
|
| 92 |
+
evidence_id: str
|
| 93 |
+
explanation: str
|
| 94 |
+
is_primary: bool = True
|
| 95 |
+
triggers_testimony: str | None = None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class Statement(BaseModel):
|
| 99 |
+
id: str
|
| 100 |
+
text: str
|
| 101 |
+
press_response: str
|
| 102 |
+
press_reveals_statement: str | None = None
|
| 103 |
+
press_grants_evidence: str | None = None
|
| 104 |
+
contradiction: Contradiction | None = None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class Testimony(BaseModel):
|
| 108 |
+
id: str
|
| 109 |
+
title: str
|
| 110 |
+
witness_id: str
|
| 111 |
+
preamble: str
|
| 112 |
+
statements: list[Statement]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class CourtRound(BaseModel):
|
| 116 |
+
witness_id: str
|
| 117 |
+
order: int
|
| 118 |
+
initial_testimony_id: str
|
| 119 |
+
testimonies: list[Testimony]
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class WinCondition(BaseModel):
|
| 123 |
+
required_contradiction_ids: list[str]
|
| 124 |
+
final_evidence_id: str | None = None
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class CourtProceedings(BaseModel):
|
| 128 |
+
penalty_limit: int = 5
|
| 129 |
+
rounds: list[CourtRound]
|
| 130 |
+
win_condition: WinCondition
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class InvestigationGate(BaseModel):
|
| 134 |
+
required_evidence: list[str]
|
| 135 |
+
auto_advance: bool = False
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class CaseData(BaseModel):
|
| 139 |
+
id: str
|
| 140 |
+
title: str
|
| 141 |
+
difficulty: Literal["easy", "hard"]
|
| 142 |
+
description: str
|
| 143 |
+
metadata: CaseMetadata = CaseMetadata()
|
| 144 |
+
characters: list[Character]
|
| 145 |
+
evidence: list[EvidenceItem]
|
| 146 |
+
locations: list[Location] = []
|
| 147 |
+
dialogues: list[DialogueTree] = []
|
| 148 |
+
court: CourtProceedings
|
| 149 |
+
investigation_gate: InvestigationGate | None = None
|
| 150 |
+
|
| 151 |
+
def get_character(self, character_id: str) -> Character | None:
|
| 152 |
+
return next((c for c in self.characters if c.id == character_id), None)
|
| 153 |
+
|
| 154 |
+
def get_evidence(self, evidence_id: str) -> EvidenceItem | None:
|
| 155 |
+
return next((e for e in self.evidence if e.id == evidence_id), None)
|
| 156 |
+
|
| 157 |
+
def get_location(self, location_id: str) -> Location | None:
|
| 158 |
+
return next((loc for loc in self.locations if loc.id == location_id), None)
|
| 159 |
+
|
| 160 |
+
def get_testimony(self, testimony_id: str) -> Testimony | None:
|
| 161 |
+
for rnd in self.court.rounds:
|
| 162 |
+
for t in rnd.testimonies:
|
| 163 |
+
if t.id == testimony_id:
|
| 164 |
+
return t
|
| 165 |
+
return None
|
| 166 |
+
|
| 167 |
+
def get_statement(self, statement_id: str) -> Statement | None:
|
| 168 |
+
for rnd in self.court.rounds:
|
| 169 |
+
for t in rnd.testimonies:
|
| 170 |
+
for s in t.statements:
|
| 171 |
+
if s.id == statement_id:
|
| 172 |
+
return s
|
| 173 |
+
return None
|
| 174 |
+
|
| 175 |
+
def get_pre_given_evidence(self) -> list[EvidenceItem]:
|
| 176 |
+
return [e for e in self.evidence if e.source == "pre_given"]
|
| 177 |
+
|
| 178 |
+
def get_all_evidence_ids(self) -> set[str]:
|
| 179 |
+
return {e.id for e in self.evidence}
|
| 180 |
+
|
| 181 |
+
def get_required_contradiction_statements(self) -> list[Statement]:
|
| 182 |
+
result = []
|
| 183 |
+
for sid in self.court.win_condition.required_contradiction_ids:
|
| 184 |
+
s = self.get_statement(sid)
|
| 185 |
+
if s:
|
| 186 |
+
result.append(s)
|
| 187 |
+
return result
|
turnabout/core/scoring.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation metrics computation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
|
| 7 |
+
from turnabout.core.engine import TurnaboutEngine
|
| 8 |
+
from turnabout.core.schema import CaseData
|
| 9 |
+
from turnabout.core.state import GameState
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class EpisodeMetrics:
|
| 14 |
+
won: bool = False
|
| 15 |
+
# Court
|
| 16 |
+
total_contradictions_required: int = 0
|
| 17 |
+
contradictions_found: int = 0
|
| 18 |
+
correct_presentations: int = 0
|
| 19 |
+
wrong_presentations: int = 0
|
| 20 |
+
contradiction_accuracy: float = 0.0
|
| 21 |
+
statements_pressed: int = 0
|
| 22 |
+
total_statements: int = 0
|
| 23 |
+
press_coverage: float = 0.0
|
| 24 |
+
# Investigation
|
| 25 |
+
evidence_available: int = 0
|
| 26 |
+
evidence_collected: int = 0
|
| 27 |
+
evidence_coverage: float = 0.0
|
| 28 |
+
required_evidence_collected: int = 0
|
| 29 |
+
required_evidence_total: int = 0
|
| 30 |
+
# Efficiency
|
| 31 |
+
total_steps: int = 0
|
| 32 |
+
optimal_steps: int | None = None
|
| 33 |
+
step_efficiency: float = 0.0
|
| 34 |
+
investigation_steps: int = 0
|
| 35 |
+
court_steps: int = 0
|
| 36 |
+
# Aggregate
|
| 37 |
+
total_reward: float = 0.0
|
| 38 |
+
composite_score: float = 0.0
|
| 39 |
+
|
| 40 |
+
def to_dict(self) -> dict:
|
| 41 |
+
return {
|
| 42 |
+
"won": self.won,
|
| 43 |
+
"total_contradictions_required": self.total_contradictions_required,
|
| 44 |
+
"contradictions_found": self.contradictions_found,
|
| 45 |
+
"correct_presentations": self.correct_presentations,
|
| 46 |
+
"wrong_presentations": self.wrong_presentations,
|
| 47 |
+
"contradiction_accuracy": self.contradiction_accuracy,
|
| 48 |
+
"statements_pressed": self.statements_pressed,
|
| 49 |
+
"total_statements": self.total_statements,
|
| 50 |
+
"press_coverage": self.press_coverage,
|
| 51 |
+
"evidence_available": self.evidence_available,
|
| 52 |
+
"evidence_collected": self.evidence_collected,
|
| 53 |
+
"evidence_coverage": self.evidence_coverage,
|
| 54 |
+
"total_steps": self.total_steps,
|
| 55 |
+
"optimal_steps": self.optimal_steps,
|
| 56 |
+
"step_efficiency": self.step_efficiency,
|
| 57 |
+
"investigation_steps": self.investigation_steps,
|
| 58 |
+
"court_steps": self.court_steps,
|
| 59 |
+
"total_reward": self.total_reward,
|
| 60 |
+
"composite_score": self.composite_score,
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class MetricsComputer:
|
| 65 |
+
def __init__(self, engine: TurnaboutEngine):
|
| 66 |
+
self.engine = engine
|
| 67 |
+
self.case = engine.case
|
| 68 |
+
self._total_reward = 0.0
|
| 69 |
+
|
| 70 |
+
def accumulate_reward(self, reward: float) -> None:
|
| 71 |
+
self._total_reward += reward
|
| 72 |
+
|
| 73 |
+
def compute(self, state: GameState) -> EpisodeMetrics:
|
| 74 |
+
m = EpisodeMetrics()
|
| 75 |
+
m.won = state.phase.value == "verdict_win"
|
| 76 |
+
m.total_reward = self._total_reward
|
| 77 |
+
|
| 78 |
+
required_ids = set(self.case.court.win_condition.required_contradiction_ids)
|
| 79 |
+
m.total_contradictions_required = len(required_ids)
|
| 80 |
+
m.contradictions_found = len(state.contradictions_found & required_ids)
|
| 81 |
+
|
| 82 |
+
m.correct_presentations = len(state.contradictions_found)
|
| 83 |
+
m.wrong_presentations = state.penalties
|
| 84 |
+
total_presentations = m.correct_presentations + m.wrong_presentations
|
| 85 |
+
m.contradiction_accuracy = (
|
| 86 |
+
m.correct_presentations / total_presentations if total_presentations > 0 else 0.0
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
total_stmts = set()
|
| 90 |
+
for rnd in self.case.court.rounds:
|
| 91 |
+
for t in rnd.testimonies:
|
| 92 |
+
for s in t.statements:
|
| 93 |
+
total_stmts.add(s.id)
|
| 94 |
+
m.total_statements = len(total_stmts)
|
| 95 |
+
m.statements_pressed = len(state.statements_pressed)
|
| 96 |
+
m.press_coverage = (
|
| 97 |
+
m.statements_pressed / m.total_statements if m.total_statements > 0 else 0.0
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
m.evidence_available = len(self.case.evidence)
|
| 101 |
+
m.evidence_collected = len(state.inventory)
|
| 102 |
+
m.evidence_coverage = (
|
| 103 |
+
m.evidence_collected / m.evidence_available if m.evidence_available > 0 else 0.0
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
if self.case.investigation_gate:
|
| 107 |
+
req = set(self.case.investigation_gate.required_evidence)
|
| 108 |
+
m.required_evidence_total = len(req)
|
| 109 |
+
m.required_evidence_collected = len(state.inventory & req)
|
| 110 |
+
|
| 111 |
+
m.total_steps = state.step_count
|
| 112 |
+
from turnabout.core.state import GamePhase
|
| 113 |
+
m.investigation_steps = sum(
|
| 114 |
+
1 for a in state.action_history if a.phase == GamePhase.INVESTIGATION
|
| 115 |
+
)
|
| 116 |
+
m.court_steps = m.total_steps - m.investigation_steps
|
| 117 |
+
|
| 118 |
+
m.composite_score = (
|
| 119 |
+
0.4 * (1.0 if m.won else 0.0)
|
| 120 |
+
+ 0.2 * m.contradiction_accuracy
|
| 121 |
+
+ 0.2 * m.step_efficiency
|
| 122 |
+
+ 0.1 * m.evidence_coverage
|
| 123 |
+
+ 0.1 * m.press_coverage
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
return m
|
turnabout/core/state.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Game phase definitions and mutable game state."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from enum import Enum
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class GamePhase(Enum):
|
| 10 |
+
INVESTIGATION = "investigation"
|
| 11 |
+
COURT_TESTIMONY = "court_testimony"
|
| 12 |
+
COURT_CROSS_EXAM = "court_cross_examination"
|
| 13 |
+
COURT_PENALTY = "court_penalty"
|
| 14 |
+
COURT_OBJECTION = "court_objection"
|
| 15 |
+
VERDICT_WIN = "verdict_win"
|
| 16 |
+
VERDICT_LOSE = "verdict_lose"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class ActionRecord:
|
| 21 |
+
step: int
|
| 22 |
+
phase: GamePhase
|
| 23 |
+
action_type: str
|
| 24 |
+
target_id: str | None = None
|
| 25 |
+
secondary_id: str | None = None
|
| 26 |
+
result: str = ""
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class GameState:
|
| 31 |
+
phase: GamePhase
|
| 32 |
+
# Investigation
|
| 33 |
+
current_location_id: str | None = None
|
| 34 |
+
inventory: set[str] = field(default_factory=set)
|
| 35 |
+
examined: set[str] = field(default_factory=set)
|
| 36 |
+
dialogue_lines_seen: set[str] = field(default_factory=set)
|
| 37 |
+
evidence_presented_to: set[tuple[str, str]] = field(default_factory=set)
|
| 38 |
+
unlocked_locations: set[str] = field(default_factory=set)
|
| 39 |
+
# Court
|
| 40 |
+
current_round_index: int = 0
|
| 41 |
+
current_testimony_id: str | None = None
|
| 42 |
+
current_statement_index: int = 0
|
| 43 |
+
penalties: int = 0
|
| 44 |
+
contradictions_found: set[str] = field(default_factory=set)
|
| 45 |
+
statements_pressed: set[str] = field(default_factory=set)
|
| 46 |
+
revealed_statements: set[str] = field(default_factory=set)
|
| 47 |
+
# Tracking
|
| 48 |
+
action_history: list[ActionRecord] = field(default_factory=list)
|
| 49 |
+
step_count: int = 0
|
| 50 |
+
|
| 51 |
+
@property
|
| 52 |
+
def is_terminal(self) -> bool:
|
| 53 |
+
return self.phase in (GamePhase.VERDICT_WIN, GamePhase.VERDICT_LOSE)
|
| 54 |
+
|
| 55 |
+
def copy(self) -> GameState:
|
| 56 |
+
return GameState(
|
| 57 |
+
phase=self.phase,
|
| 58 |
+
current_location_id=self.current_location_id,
|
| 59 |
+
inventory=set(self.inventory),
|
| 60 |
+
examined=set(self.examined),
|
| 61 |
+
dialogue_lines_seen=set(self.dialogue_lines_seen),
|
| 62 |
+
evidence_presented_to=set(self.evidence_presented_to),
|
| 63 |
+
unlocked_locations=set(self.unlocked_locations),
|
| 64 |
+
current_round_index=self.current_round_index,
|
| 65 |
+
current_testimony_id=self.current_testimony_id,
|
| 66 |
+
current_statement_index=self.current_statement_index,
|
| 67 |
+
penalties=self.penalties,
|
| 68 |
+
contradictions_found=set(self.contradictions_found),
|
| 69 |
+
statements_pressed=set(self.statements_pressed),
|
| 70 |
+
revealed_statements=set(self.revealed_statements),
|
| 71 |
+
action_history=list(self.action_history),
|
| 72 |
+
step_count=self.step_count,
|
| 73 |
+
)
|
turnabout/envs/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Environment registration for Gymnasium."""
|
| 2 |
+
|
| 3 |
+
from gymnasium.envs.registration import register
|
| 4 |
+
|
| 5 |
+
register(
|
| 6 |
+
id="Turnabout-Text-v0",
|
| 7 |
+
entry_point="turnabout.envs.gym_env:TurnaboutEnv",
|
| 8 |
+
kwargs={"mode": "text"},
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
register(
|
| 12 |
+
id="Turnabout-Discrete-v0",
|
| 13 |
+
entry_point="turnabout.envs.gym_env:TurnaboutEnv",
|
| 14 |
+
kwargs={"mode": "discrete"},
|
| 15 |
+
)
|
turnabout/envs/gym_env.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gymnasium-compatible environment wrapper."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any, Literal
|
| 8 |
+
|
| 9 |
+
import gymnasium as gym
|
| 10 |
+
import numpy as np
|
| 11 |
+
from gymnasium import spaces
|
| 12 |
+
|
| 13 |
+
from turnabout.core.actions import ActionParser, DiscreteActionEncoder
|
| 14 |
+
from turnabout.core.engine import TurnaboutEngine
|
| 15 |
+
from turnabout.core.schema import CaseData
|
| 16 |
+
from turnabout.core.scoring import MetricsComputer
|
| 17 |
+
from turnabout.core.state import GamePhase
|
| 18 |
+
from turnabout.envs.renderer import Renderer
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class TurnaboutEnv(gym.Env):
|
| 22 |
+
"""Gymnasium environment for Turnabout.
|
| 23 |
+
|
| 24 |
+
Supports two modes:
|
| 25 |
+
- "text": observation and action spaces are Text
|
| 26 |
+
- "discrete": observation is Dict, action is Discrete with masking
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
metadata = {"render_modes": ["human", "ansi"]}
|
| 30 |
+
|
| 31 |
+
def __init__(
|
| 32 |
+
self,
|
| 33 |
+
case_path: str | None = None,
|
| 34 |
+
case_data: dict | None = None,
|
| 35 |
+
difficulty: str | None = None,
|
| 36 |
+
mode: Literal["text", "discrete"] = "discrete",
|
| 37 |
+
render_mode: str | None = None,
|
| 38 |
+
reward_config: dict | None = None,
|
| 39 |
+
max_steps: int = 200,
|
| 40 |
+
):
|
| 41 |
+
super().__init__()
|
| 42 |
+
self.render_mode = render_mode
|
| 43 |
+
self.mode = mode
|
| 44 |
+
self.max_steps = max_steps
|
| 45 |
+
|
| 46 |
+
if case_path:
|
| 47 |
+
with open(case_path) as f:
|
| 48 |
+
case_data = json.load(f)
|
| 49 |
+
if case_data is None:
|
| 50 |
+
raise ValueError("Must provide case_path or case_data")
|
| 51 |
+
|
| 52 |
+
self.case = CaseData(**case_data) if isinstance(case_data, dict) else case_data
|
| 53 |
+
self.engine = TurnaboutEngine(self.case, difficulty=difficulty, rewards=reward_config)
|
| 54 |
+
self.renderer = Renderer(self.case)
|
| 55 |
+
self.parser = ActionParser(self.case)
|
| 56 |
+
self.metrics_computer: MetricsComputer | None = None
|
| 57 |
+
self._step_count = 0
|
| 58 |
+
|
| 59 |
+
if mode == "text":
|
| 60 |
+
self.observation_space = spaces.Text(max_length=8192)
|
| 61 |
+
self.action_space = spaces.Text(max_length=256)
|
| 62 |
+
else:
|
| 63 |
+
self.action_encoder = DiscreteActionEncoder(self.case)
|
| 64 |
+
n_evidence = len(self.case.evidence)
|
| 65 |
+
n_phases = len(GamePhase)
|
| 66 |
+
n_locations = len(self.case.locations) + 1
|
| 67 |
+
|
| 68 |
+
total_stmts = 0
|
| 69 |
+
for rnd in self.case.court.rounds:
|
| 70 |
+
for t in rnd.testimonies:
|
| 71 |
+
total_stmts += len(t.statements)
|
| 72 |
+
|
| 73 |
+
self.observation_space = spaces.Dict({
|
| 74 |
+
"phase": spaces.Discrete(n_phases),
|
| 75 |
+
"location": spaces.Discrete(n_locations),
|
| 76 |
+
"evidence_held": spaces.MultiBinary(n_evidence),
|
| 77 |
+
"current_statement": spaces.Discrete(max(total_stmts, 1) + 1),
|
| 78 |
+
"penalties": spaces.Discrete(self.case.court.penalty_limit + 1),
|
| 79 |
+
"contradictions_found": spaces.MultiBinary(
|
| 80 |
+
len(self.case.court.win_condition.required_contradiction_ids)
|
| 81 |
+
),
|
| 82 |
+
"text_obs": spaces.Text(max_length=2048),
|
| 83 |
+
})
|
| 84 |
+
self.action_space = spaces.Discrete(self.action_encoder.n_actions)
|
| 85 |
+
|
| 86 |
+
self._phase_to_int = {p: i for i, p in enumerate(GamePhase)}
|
| 87 |
+
self._location_to_int = {loc.id: i for i, loc in enumerate(self.case.locations)}
|
| 88 |
+
self._evidence_ids = [e.id for e in self.case.evidence]
|
| 89 |
+
self._required_ids = list(self.case.court.win_condition.required_contradiction_ids)
|
| 90 |
+
|
| 91 |
+
def reset(self, seed=None, options=None) -> tuple[Any, dict]:
|
| 92 |
+
super().reset(seed=seed)
|
| 93 |
+
state, obs_text = self.engine.reset()
|
| 94 |
+
self._step_count = 0
|
| 95 |
+
self.metrics_computer = MetricsComputer(self.engine)
|
| 96 |
+
|
| 97 |
+
full_obs = self.renderer.render(state, obs_text)
|
| 98 |
+
|
| 99 |
+
if self.mode == "text":
|
| 100 |
+
return full_obs, self._make_info(state)
|
| 101 |
+
return self._make_discrete_obs(state, full_obs), self._make_info(state)
|
| 102 |
+
|
| 103 |
+
def step(self, action) -> tuple[Any, float, bool, bool, dict]:
|
| 104 |
+
if self.mode == "text":
|
| 105 |
+
parsed = self.parser.parse(action)
|
| 106 |
+
if parsed is None:
|
| 107 |
+
obs_text = f"Invalid action: {action}"
|
| 108 |
+
reward = -0.05
|
| 109 |
+
state = self.engine.state
|
| 110 |
+
done = False
|
| 111 |
+
else:
|
| 112 |
+
state, obs_text, reward, done, _ = self.engine.step(parsed)
|
| 113 |
+
else:
|
| 114 |
+
parsed = self.action_encoder.decode(action)
|
| 115 |
+
state, obs_text, reward, done, _ = self.engine.step(parsed)
|
| 116 |
+
|
| 117 |
+
self._step_count += 1
|
| 118 |
+
self.metrics_computer.accumulate_reward(reward)
|
| 119 |
+
|
| 120 |
+
truncated = False
|
| 121 |
+
if self._step_count >= self.max_steps and not done:
|
| 122 |
+
truncated = True
|
| 123 |
+
|
| 124 |
+
full_obs = self.renderer.render(state, obs_text)
|
| 125 |
+
|
| 126 |
+
if self.mode == "text":
|
| 127 |
+
obs = full_obs
|
| 128 |
+
else:
|
| 129 |
+
obs = self._make_discrete_obs(state, full_obs)
|
| 130 |
+
|
| 131 |
+
return obs, reward, done, truncated, self._make_info(state)
|
| 132 |
+
|
| 133 |
+
def action_masks(self) -> np.ndarray:
|
| 134 |
+
if self.mode != "discrete":
|
| 135 |
+
raise ValueError("action_masks() only available in discrete mode")
|
| 136 |
+
valid = self.engine.get_valid_actions()
|
| 137 |
+
return self.action_encoder.get_action_mask(valid)
|
| 138 |
+
|
| 139 |
+
def render(self) -> str | None:
|
| 140 |
+
if self.engine.state is None:
|
| 141 |
+
return None
|
| 142 |
+
text = self.renderer.render(self.engine.state)
|
| 143 |
+
if self.render_mode == "human":
|
| 144 |
+
print(text)
|
| 145 |
+
return None
|
| 146 |
+
return text
|
| 147 |
+
|
| 148 |
+
def close(self) -> None:
|
| 149 |
+
pass
|
| 150 |
+
|
| 151 |
+
def _make_discrete_obs(self, state, text_obs: str) -> dict:
|
| 152 |
+
phase_int = self._phase_to_int.get(state.phase, 0)
|
| 153 |
+
loc_int = self._location_to_int.get(state.current_location_id, len(self.case.locations))
|
| 154 |
+
evidence_held = np.array(
|
| 155 |
+
[1 if eid in state.inventory else 0 for eid in self._evidence_ids],
|
| 156 |
+
dtype=np.int8,
|
| 157 |
+
)
|
| 158 |
+
contradictions = np.array(
|
| 159 |
+
[1 if sid in state.contradictions_found else 0 for sid in self._required_ids],
|
| 160 |
+
dtype=np.int8,
|
| 161 |
+
)
|
| 162 |
+
return {
|
| 163 |
+
"phase": phase_int,
|
| 164 |
+
"location": loc_int,
|
| 165 |
+
"evidence_held": evidence_held,
|
| 166 |
+
"current_statement": state.current_statement_index,
|
| 167 |
+
"penalties": state.penalties,
|
| 168 |
+
"contradictions_found": contradictions,
|
| 169 |
+
"text_obs": text_obs[:2048],
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
def _make_info(self, state) -> dict:
|
| 173 |
+
info = {
|
| 174 |
+
"phase": state.phase.value,
|
| 175 |
+
"penalties": state.penalties,
|
| 176 |
+
"step": self._step_count,
|
| 177 |
+
"evidence_held": sorted(state.inventory),
|
| 178 |
+
}
|
| 179 |
+
if self.mode == "discrete":
|
| 180 |
+
info["action_mask"] = self.action_masks()
|
| 181 |
+
return info
|
turnabout/envs/renderer.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Render game state into human-readable text observations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from turnabout.core.actions import Action, ActionType
|
| 6 |
+
from turnabout.core.schema import CaseData
|
| 7 |
+
from turnabout.core.state import GamePhase, GameState
|
| 8 |
+
from turnabout.i18n import t
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Renderer:
|
| 12 |
+
def __init__(self, case: CaseData, verbose: bool = True, lang: str = "en"):
|
| 13 |
+
self.case = case
|
| 14 |
+
self.verbose = verbose
|
| 15 |
+
self.lang = lang
|
| 16 |
+
|
| 17 |
+
def render(self, state: GameState, action_result: str = "") -> str:
|
| 18 |
+
parts = []
|
| 19 |
+
if action_result:
|
| 20 |
+
parts.append(action_result)
|
| 21 |
+
|
| 22 |
+
if state.phase == GamePhase.INVESTIGATION:
|
| 23 |
+
parts.append(self._render_investigation(state))
|
| 24 |
+
elif state.phase in (GamePhase.COURT_TESTIMONY, GamePhase.COURT_CROSS_EXAM):
|
| 25 |
+
parts.append(self._render_court(state))
|
| 26 |
+
elif state.phase == GamePhase.VERDICT_WIN:
|
| 27 |
+
parts.append(t("not_guilty_short", self.lang))
|
| 28 |
+
elif state.phase == GamePhase.VERDICT_LOSE:
|
| 29 |
+
parts.append(t("guilty_short", self.lang))
|
| 30 |
+
|
| 31 |
+
if self.verbose:
|
| 32 |
+
parts.append(self._render_evidence_list(state))
|
| 33 |
+
|
| 34 |
+
return "\n\n".join(p for p in parts if p)
|
| 35 |
+
|
| 36 |
+
def _render_investigation(self, state: GameState) -> str:
|
| 37 |
+
loc = self.case.get_location(state.current_location_id)
|
| 38 |
+
if not loc:
|
| 39 |
+
return t("investigation_header", self.lang)
|
| 40 |
+
|
| 41 |
+
lines = [t("investigation_header", self.lang), t("location_label", self.lang).format(name=loc.name), loc.description]
|
| 42 |
+
|
| 43 |
+
people = []
|
| 44 |
+
for cid in loc.characters_present:
|
| 45 |
+
ch = self.case.get_character(cid)
|
| 46 |
+
if ch:
|
| 47 |
+
people.append(f"{ch.name} ({ch.role})")
|
| 48 |
+
if people:
|
| 49 |
+
lines.append("\n" + t("people_here", self.lang).format(names=', '.join(people)))
|
| 50 |
+
|
| 51 |
+
if loc.examinables:
|
| 52 |
+
names = [ex.name for ex in loc.examinables]
|
| 53 |
+
lines.append(t("examine_label", self.lang).format(names=', '.join(names)))
|
| 54 |
+
|
| 55 |
+
connected = []
|
| 56 |
+
for cid in loc.connected_to:
|
| 57 |
+
if cid in state.unlocked_locations:
|
| 58 |
+
c = self.case.get_location(cid)
|
| 59 |
+
if c:
|
| 60 |
+
connected.append(c.name)
|
| 61 |
+
if connected:
|
| 62 |
+
lines.append(t("travel_to", self.lang).format(names=', '.join(connected)))
|
| 63 |
+
|
| 64 |
+
return "\n".join(lines)
|
| 65 |
+
|
| 66 |
+
def _render_court(self, state: GameState) -> str:
|
| 67 |
+
testimony = self.case.get_testimony(state.current_testimony_id)
|
| 68 |
+
if not testimony:
|
| 69 |
+
return t("court_header", self.lang)
|
| 70 |
+
|
| 71 |
+
witness = self.case.get_character(testimony.witness_id)
|
| 72 |
+
header = t("testimony_header", self.lang) if state.phase == GamePhase.COURT_TESTIMONY else t("cross_exam_header", self.lang)
|
| 73 |
+
lines = [
|
| 74 |
+
header,
|
| 75 |
+
t("witness_label", self.lang).format(name=witness.name if witness else '???'),
|
| 76 |
+
t("testimony_label", self.lang).format(title=testimony.title),
|
| 77 |
+
"",
|
| 78 |
+
]
|
| 79 |
+
|
| 80 |
+
visible = self._visible_statements(state, testimony)
|
| 81 |
+
for i, stmt in enumerate(visible):
|
| 82 |
+
marker = ">" if i == state.current_statement_index else " "
|
| 83 |
+
lines.append(f" {marker} {i + 1}. \"{stmt.text}\"")
|
| 84 |
+
|
| 85 |
+
lines.append("\n" + t("penalties_label", self.lang).format(cur=state.penalties, max=self.case.court.penalty_limit))
|
| 86 |
+
|
| 87 |
+
if state.phase == GamePhase.COURT_CROSS_EXAM:
|
| 88 |
+
lines.append(t("actions_hint", self.lang))
|
| 89 |
+
|
| 90 |
+
return "\n".join(lines)
|
| 91 |
+
|
| 92 |
+
def _render_evidence_list(self, state: GameState) -> str:
|
| 93 |
+
if not state.inventory:
|
| 94 |
+
return ""
|
| 95 |
+
lines = [t("evidence_header", self.lang)]
|
| 96 |
+
for i, eid in enumerate(sorted(state.inventory), 1):
|
| 97 |
+
ev = self.case.get_evidence(eid)
|
| 98 |
+
if ev:
|
| 99 |
+
desc = ev.detail if ev.detail else ev.description
|
| 100 |
+
lines.append(f" {i}. {ev.name} - {desc}")
|
| 101 |
+
return "\n".join(lines)
|
| 102 |
+
|
| 103 |
+
def _visible_statements(self, state, testimony):
|
| 104 |
+
visible = []
|
| 105 |
+
for s in testimony.statements:
|
| 106 |
+
if s.press_reveals_statement and s.id not in state.revealed_statements:
|
| 107 |
+
if any(
|
| 108 |
+
prev.press_reveals_statement == s.id
|
| 109 |
+
for prev in testimony.statements
|
| 110 |
+
if prev.id != s.id
|
| 111 |
+
):
|
| 112 |
+
continue
|
| 113 |
+
visible.append(s)
|
| 114 |
+
return visible
|
| 115 |
+
|
| 116 |
+
def render_valid_actions(self, valid_actions: list[Action]) -> str:
|
| 117 |
+
if not valid_actions:
|
| 118 |
+
return ""
|
| 119 |
+
lines = ["[Available Actions]"]
|
| 120 |
+
for a in valid_actions:
|
| 121 |
+
lines.append(f" {a}")
|
| 122 |
+
return "\n".join(lines)
|
| 123 |
+
|
| 124 |
+
def render_system_prompt(self) -> str:
|
| 125 |
+
return (
|
| 126 |
+
"You are playing as a defense attorney in a courtroom game.\n\n"
|
| 127 |
+
"INVESTIGATION PHASE:\n"
|
| 128 |
+
" move <location> - Travel to a location\n"
|
| 129 |
+
" examine <object> - Examine an object or area\n"
|
| 130 |
+
" talk <person> - Talk to a character\n"
|
| 131 |
+
" present <evidence> to <person> - Show evidence to someone\n"
|
| 132 |
+
" go to court - Proceed to court (when ready)\n\n"
|
| 133 |
+
"COURT PHASE:\n"
|
| 134 |
+
" press - Press the witness on the current statement\n"
|
| 135 |
+
" present <evidence> - Present evidence that contradicts the current statement\n"
|
| 136 |
+
" next - Move to the next statement\n"
|
| 137 |
+
" prev - Move to the previous statement\n\n"
|
| 138 |
+
"Find contradictions in witness testimony by presenting the right evidence.\n"
|
| 139 |
+
"Wrong evidence = penalty from the judge. Too many penalties = you lose.\n"
|
| 140 |
+
f"\nCase: {self.case.title}\n{self.case.description}"
|
| 141 |
+
)
|
turnabout/envs/text_env.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Text-based environment for LLM agents. String in, string out."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Literal
|
| 8 |
+
|
| 9 |
+
from turnabout.core.actions import ActionParser
|
| 10 |
+
from turnabout.core.engine import TurnaboutEngine
|
| 11 |
+
from turnabout.core.schema import CaseData
|
| 12 |
+
from turnabout.core.scoring import EpisodeMetrics, MetricsComputer
|
| 13 |
+
from turnabout.envs.renderer import Renderer
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class TextCourtEnv:
|
| 17 |
+
"""Text-based environment suitable for LLM agents.
|
| 18 |
+
|
| 19 |
+
Not a Gymnasium env. Provides a simpler string-in/string-out interface.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
def __init__(
|
| 23 |
+
self,
|
| 24 |
+
case_path: str | Path | None = None,
|
| 25 |
+
case_data: dict | None = None,
|
| 26 |
+
difficulty: Literal["easy", "hard"] | None = None,
|
| 27 |
+
verbose: bool = True,
|
| 28 |
+
max_steps: int = 200,
|
| 29 |
+
lang: str = "en",
|
| 30 |
+
):
|
| 31 |
+
if case_path:
|
| 32 |
+
with open(case_path) as f:
|
| 33 |
+
case_data = json.load(f)
|
| 34 |
+
if case_data is None:
|
| 35 |
+
raise ValueError("Must provide case_path or case_data")
|
| 36 |
+
|
| 37 |
+
self.case = CaseData(**case_data) if isinstance(case_data, dict) else case_data
|
| 38 |
+
self.lang = lang
|
| 39 |
+
self.engine = TurnaboutEngine(self.case, difficulty=difficulty, lang=lang)
|
| 40 |
+
self.parser = ActionParser(self.case)
|
| 41 |
+
self.renderer = Renderer(self.case, verbose=verbose, lang=lang)
|
| 42 |
+
self.metrics_computer = MetricsComputer(self.engine)
|
| 43 |
+
self.max_steps = max_steps
|
| 44 |
+
self._step_count = 0
|
| 45 |
+
|
| 46 |
+
def reset(self) -> str:
|
| 47 |
+
state, engine_obs = self.engine.reset()
|
| 48 |
+
self._step_count = 0
|
| 49 |
+
self.metrics_computer = MetricsComputer(self.engine)
|
| 50 |
+
return self.renderer.render(state, engine_obs)
|
| 51 |
+
|
| 52 |
+
def step(self, action_text: str) -> tuple[str, float, bool, dict]:
|
| 53 |
+
"""Process a text action.
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
observation: Text observation after the action
|
| 57 |
+
reward: Numerical reward
|
| 58 |
+
done: Whether the episode is over
|
| 59 |
+
info: Dict with valid_actions, phase, penalties, parse_error, etc.
|
| 60 |
+
"""
|
| 61 |
+
action = self.parser.parse(action_text)
|
| 62 |
+
if action is None:
|
| 63 |
+
valid = self.engine.get_valid_actions()
|
| 64 |
+
valid_text = [str(a) for a in valid]
|
| 65 |
+
obs = (
|
| 66 |
+
f"Could not parse action: '{action_text}'\n\n"
|
| 67 |
+
f"Available commands:\n" +
|
| 68 |
+
"\n".join(f" {v}" for v in valid_text)
|
| 69 |
+
)
|
| 70 |
+
return obs, -0.05, False, {
|
| 71 |
+
"valid_actions": valid_text,
|
| 72 |
+
"phase": self.engine.state.phase.value,
|
| 73 |
+
"parse_error": action_text,
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
state, engine_obs, reward, done, info = self.engine.step(action)
|
| 77 |
+
self._step_count += 1
|
| 78 |
+
self.metrics_computer.accumulate_reward(reward)
|
| 79 |
+
|
| 80 |
+
if self._step_count >= self.max_steps and not done:
|
| 81 |
+
done = True
|
| 82 |
+
engine_obs += "\n\n[Time limit reached. Case dismissed.]"
|
| 83 |
+
|
| 84 |
+
obs = self.renderer.render(state, engine_obs)
|
| 85 |
+
valid = self.engine.get_valid_actions()
|
| 86 |
+
|
| 87 |
+
info.update({
|
| 88 |
+
"valid_actions": [str(a) for a in valid],
|
| 89 |
+
"phase": state.phase.value,
|
| 90 |
+
"penalties": state.penalties,
|
| 91 |
+
"evidence_held": sorted(state.inventory),
|
| 92 |
+
"step": self._step_count,
|
| 93 |
+
})
|
| 94 |
+
|
| 95 |
+
return obs, reward, done, info
|
| 96 |
+
|
| 97 |
+
def get_metrics(self) -> EpisodeMetrics:
|
| 98 |
+
return self.metrics_computer.compute(self.engine.state)
|
| 99 |
+
|
| 100 |
+
@property
|
| 101 |
+
def system_prompt(self) -> str:
|
| 102 |
+
return self.renderer.render_system_prompt()
|
| 103 |
+
|
| 104 |
+
def render(self) -> str:
|
| 105 |
+
return self.renderer.render(self.engine.state)
|
turnabout/generation/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Case generation pipeline for Turnabout."""
|
| 2 |
+
|
| 3 |
+
from turnabout.generation.validator import CaseValidator, ValidationReport
|
| 4 |
+
|
| 5 |
+
__all__ = ["CaseValidator", "ValidationReport"]
|
turnabout/generation/generator.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-step LLM case generator with validation and retry logic.
|
| 2 |
+
|
| 3 |
+
Supports both Anthropic SDK and OpenAI-compatible APIs (e.g. custom
|
| 4 |
+
gateways). The module is importable without either SDK installed; a
|
| 5 |
+
missing SDK only raises an error when ``generate()`` is actually called.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
import os
|
| 13 |
+
import re
|
| 14 |
+
from typing import Any, Literal
|
| 15 |
+
|
| 16 |
+
from turnabout.core.schema import CaseData
|
| 17 |
+
from turnabout.generation.prompts import (
|
| 18 |
+
CHARACTERS_EVIDENCE_PROMPT,
|
| 19 |
+
INVESTIGATION_PROMPT,
|
| 20 |
+
NARRATIVE_PROMPT,
|
| 21 |
+
TESTIMONY_PROMPT,
|
| 22 |
+
)
|
| 23 |
+
from turnabout.generation.validator import CaseValidator, ValidationReport
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
_DEFAULT_MODEL = "claude-sonnet-4-6"
|
| 28 |
+
_MAX_RETRIES = 3
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _extract_json(text: str) -> dict[str, Any]:
|
| 32 |
+
"""Extract JSON from LLM response text."""
|
| 33 |
+
match = re.search(r"```(?:json)?\s*\n?(.*?)```", text, re.DOTALL)
|
| 34 |
+
if match:
|
| 35 |
+
return json.loads(match.group(1).strip())
|
| 36 |
+
start = text.find("{")
|
| 37 |
+
end = text.rfind("}")
|
| 38 |
+
if start != -1 and end != -1 and end > start:
|
| 39 |
+
return json.loads(text[start : end + 1])
|
| 40 |
+
raise ValueError("No valid JSON found in LLM response")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class CaseGenerator:
|
| 44 |
+
"""Generates Ace Attorney case data via multi-step LLM pipeline."""
|
| 45 |
+
|
| 46 |
+
def __init__(
|
| 47 |
+
self,
|
| 48 |
+
llm_client: Any | None = None,
|
| 49 |
+
model: str | None = None,
|
| 50 |
+
backend: Literal["anthropic", "openai"] | None = None,
|
| 51 |
+
base_url: str | None = None,
|
| 52 |
+
api_key: str | None = None,
|
| 53 |
+
):
|
| 54 |
+
"""Initialize the generator.
|
| 55 |
+
|
| 56 |
+
Parameters
|
| 57 |
+
----------
|
| 58 |
+
llm_client:
|
| 59 |
+
A pre-built client (Anthropic or OpenAI). If provided,
|
| 60 |
+
backend/base_url/api_key are ignored.
|
| 61 |
+
model:
|
| 62 |
+
Model identifier. Defaults based on backend or env vars.
|
| 63 |
+
backend:
|
| 64 |
+
"anthropic" or "openai". Auto-detected from env vars if None.
|
| 65 |
+
base_url:
|
| 66 |
+
Custom API base URL (for OpenAI-compatible gateways).
|
| 67 |
+
Falls back to GATEWAY_URL env var.
|
| 68 |
+
api_key:
|
| 69 |
+
API key. Falls back to GATEWAY_API_KEY or standard env vars.
|
| 70 |
+
"""
|
| 71 |
+
self._client = llm_client
|
| 72 |
+
self._validator = CaseValidator()
|
| 73 |
+
|
| 74 |
+
if llm_client is not None:
|
| 75 |
+
self._backend = backend or "anthropic"
|
| 76 |
+
self._model = model or _DEFAULT_MODEL
|
| 77 |
+
return
|
| 78 |
+
|
| 79 |
+
gateway_url = base_url or os.environ.get("GATEWAY_URL")
|
| 80 |
+
gateway_key = api_key or os.environ.get("GATEWAY_API_KEY")
|
| 81 |
+
gateway_model = os.environ.get("GATEWAY_MODELS")
|
| 82 |
+
|
| 83 |
+
if backend == "openai" or (backend is None and gateway_url):
|
| 84 |
+
self._backend = "openai"
|
| 85 |
+
self._base_url = gateway_url
|
| 86 |
+
self._api_key = gateway_key
|
| 87 |
+
self._model = model or gateway_model or "gpt-4"
|
| 88 |
+
else:
|
| 89 |
+
self._backend = "anthropic"
|
| 90 |
+
self._base_url = None
|
| 91 |
+
self._api_key = None
|
| 92 |
+
self._model = model or _DEFAULT_MODEL
|
| 93 |
+
|
| 94 |
+
def _get_client(self) -> Any:
|
| 95 |
+
if self._client is not None:
|
| 96 |
+
return self._client
|
| 97 |
+
|
| 98 |
+
if self._backend == "openai":
|
| 99 |
+
try:
|
| 100 |
+
from openai import OpenAI
|
| 101 |
+
except ImportError as exc:
|
| 102 |
+
raise ImportError(
|
| 103 |
+
"The 'openai' package is required for OpenAI-compatible "
|
| 104 |
+
"gateway. Install with: pip install openai"
|
| 105 |
+
) from exc
|
| 106 |
+
kwargs: dict[str, Any] = {}
|
| 107 |
+
if self._base_url:
|
| 108 |
+
kwargs["base_url"] = self._base_url
|
| 109 |
+
if self._api_key:
|
| 110 |
+
kwargs["api_key"] = self._api_key
|
| 111 |
+
self._client = OpenAI(**kwargs)
|
| 112 |
+
else:
|
| 113 |
+
try:
|
| 114 |
+
import anthropic
|
| 115 |
+
except ImportError as exc:
|
| 116 |
+
raise ImportError(
|
| 117 |
+
"The 'anthropic' package is required. "
|
| 118 |
+
"Install with: pip install anthropic"
|
| 119 |
+
) from exc
|
| 120 |
+
self._client = anthropic.Anthropic()
|
| 121 |
+
return self._client
|
| 122 |
+
|
| 123 |
+
def _call_llm(self, prompt: str) -> str:
|
| 124 |
+
client = self._get_client()
|
| 125 |
+
if self._backend == "openai":
|
| 126 |
+
response = client.chat.completions.create(
|
| 127 |
+
model=self._model,
|
| 128 |
+
max_tokens=8192,
|
| 129 |
+
messages=[{"role": "user", "content": prompt}],
|
| 130 |
+
temperature=0.7,
|
| 131 |
+
)
|
| 132 |
+
return response.choices[0].message.content
|
| 133 |
+
else:
|
| 134 |
+
message = client.messages.create(
|
| 135 |
+
model=self._model,
|
| 136 |
+
max_tokens=8192,
|
| 137 |
+
messages=[{"role": "user", "content": prompt}],
|
| 138 |
+
)
|
| 139 |
+
return message.content[0].text
|
| 140 |
+
|
| 141 |
+
def _call_llm_json(self, prompt: str) -> dict[str, Any]:
|
| 142 |
+
text = self._call_llm(prompt)
|
| 143 |
+
return _extract_json(text)
|
| 144 |
+
|
| 145 |
+
async def generate(
|
| 146 |
+
self,
|
| 147 |
+
theme: str | None = None,
|
| 148 |
+
difficulty: str = "easy",
|
| 149 |
+
) -> CaseData:
|
| 150 |
+
"""Full generation pipeline with validation and retries.
|
| 151 |
+
|
| 152 |
+
Steps:
|
| 153 |
+
1. Generate crime narrative
|
| 154 |
+
2. Generate characters and evidence
|
| 155 |
+
3. Generate testimonies with contradictions
|
| 156 |
+
4. Generate investigation phase (hard mode only)
|
| 157 |
+
5. Assemble and validate CaseData
|
| 158 |
+
|
| 159 |
+
Each step is validated before proceeding. If validation fails,
|
| 160 |
+
the step is retried up to ``_MAX_RETRIES`` times.
|
| 161 |
+
|
| 162 |
+
Parameters
|
| 163 |
+
----------
|
| 164 |
+
theme:
|
| 165 |
+
Optional theme hint (e.g. "corporate espionage", "art heist").
|
| 166 |
+
A random theme is chosen if *None*.
|
| 167 |
+
difficulty:
|
| 168 |
+
"easy" (court only) or "hard" (investigation + court).
|
| 169 |
+
|
| 170 |
+
Returns
|
| 171 |
+
-------
|
| 172 |
+
CaseData
|
| 173 |
+
A validated case ready for use in the engine.
|
| 174 |
+
"""
|
| 175 |
+
if difficulty not in ("easy", "hard"):
|
| 176 |
+
raise ValueError(f"difficulty must be 'easy' or 'hard', got '{difficulty}'")
|
| 177 |
+
|
| 178 |
+
theme = theme or "a mysterious crime"
|
| 179 |
+
|
| 180 |
+
# Step 1: Narrative
|
| 181 |
+
logger.info("Step 1: Generating narrative...")
|
| 182 |
+
narrative = await self._step_with_retry(
|
| 183 |
+
"narrative",
|
| 184 |
+
lambda: self._generate_narrative(theme, difficulty),
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# Step 2: Characters & evidence
|
| 188 |
+
logger.info("Step 2: Generating characters and evidence...")
|
| 189 |
+
chars_evidence = await self._step_with_retry(
|
| 190 |
+
"characters_evidence",
|
| 191 |
+
lambda: self._generate_characters_evidence(narrative, difficulty),
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
# Step 3: Testimonies
|
| 195 |
+
logger.info("Step 3: Generating testimonies...")
|
| 196 |
+
court = await self._step_with_retry(
|
| 197 |
+
"testimonies",
|
| 198 |
+
lambda: self._generate_testimonies(
|
| 199 |
+
narrative, chars_evidence, difficulty
|
| 200 |
+
),
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Step 4: Investigation (hard mode)
|
| 204 |
+
investigation = None
|
| 205 |
+
if difficulty == "hard":
|
| 206 |
+
logger.info("Step 4: Generating investigation phase...")
|
| 207 |
+
investigation = await self._step_with_retry(
|
| 208 |
+
"investigation",
|
| 209 |
+
lambda: self._generate_investigation(
|
| 210 |
+
narrative, chars_evidence
|
| 211 |
+
),
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
# Step 5: Assemble
|
| 215 |
+
logger.info("Assembling final case...")
|
| 216 |
+
case = self._assemble_case(
|
| 217 |
+
narrative, chars_evidence, court, investigation, difficulty
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# Final validation
|
| 221 |
+
report = self._validator.validate(case)
|
| 222 |
+
if not report.is_valid:
|
| 223 |
+
logger.warning(
|
| 224 |
+
"Final case has validation errors: %s", report.errors
|
| 225 |
+
)
|
| 226 |
+
raise ValueError(
|
| 227 |
+
f"Generated case failed validation: {report.errors}"
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
if report.warnings:
|
| 231 |
+
logger.info("Validation warnings: %s", report.warnings)
|
| 232 |
+
|
| 233 |
+
return case
|
| 234 |
+
|
| 235 |
+
async def _step_with_retry(
|
| 236 |
+
self,
|
| 237 |
+
step_name: str,
|
| 238 |
+
fn: Any,
|
| 239 |
+
) -> dict[str, Any]:
|
| 240 |
+
"""Execute a generation step with retry on failure."""
|
| 241 |
+
last_error: Exception | None = None
|
| 242 |
+
for attempt in range(1, _MAX_RETRIES + 1):
|
| 243 |
+
try:
|
| 244 |
+
result = fn()
|
| 245 |
+
logger.info(
|
| 246 |
+
"Step '%s' succeeded on attempt %d", step_name, attempt
|
| 247 |
+
)
|
| 248 |
+
return result
|
| 249 |
+
except Exception as exc:
|
| 250 |
+
last_error = exc
|
| 251 |
+
logger.warning(
|
| 252 |
+
"Step '%s' attempt %d failed: %s",
|
| 253 |
+
step_name,
|
| 254 |
+
attempt,
|
| 255 |
+
exc,
|
| 256 |
+
)
|
| 257 |
+
raise RuntimeError(
|
| 258 |
+
f"Step '{step_name}' failed after {_MAX_RETRIES} attempts: "
|
| 259 |
+
f"{last_error}"
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
# ------------------------------------------------------------------
|
| 263 |
+
# Individual generation steps
|
| 264 |
+
# ------------------------------------------------------------------
|
| 265 |
+
|
| 266 |
+
def _generate_narrative(
|
| 267 |
+
self, theme: str, difficulty: str
|
| 268 |
+
) -> dict[str, Any]:
|
| 269 |
+
prompt = NARRATIVE_PROMPT.format(theme=theme, difficulty=difficulty)
|
| 270 |
+
result = self._call_llm_json(prompt)
|
| 271 |
+
|
| 272 |
+
# Basic structural validation
|
| 273 |
+
required = {"title", "description", "defendant", "true_culprit", "witnesses"}
|
| 274 |
+
missing = required - set(result.keys())
|
| 275 |
+
if missing:
|
| 276 |
+
raise ValueError(f"Narrative missing required fields: {missing}")
|
| 277 |
+
return result
|
| 278 |
+
|
| 279 |
+
def _generate_characters_evidence(
|
| 280 |
+
self, narrative: dict[str, Any], difficulty: str
|
| 281 |
+
) -> dict[str, Any]:
|
| 282 |
+
prompt = CHARACTERS_EVIDENCE_PROMPT.format(
|
| 283 |
+
narrative_json=json.dumps(narrative, indent=2),
|
| 284 |
+
difficulty=difficulty,
|
| 285 |
+
)
|
| 286 |
+
result = self._call_llm_json(prompt)
|
| 287 |
+
|
| 288 |
+
if "characters" not in result or "evidence" not in result:
|
| 289 |
+
raise ValueError("Response missing 'characters' or 'evidence'")
|
| 290 |
+
if not isinstance(result["characters"], list) or len(result["characters"]) < 2:
|
| 291 |
+
raise ValueError("Must have at least 2 characters")
|
| 292 |
+
if not isinstance(result["evidence"], list) or len(result["evidence"]) < 2:
|
| 293 |
+
raise ValueError("Must have at least 2 evidence items")
|
| 294 |
+
|
| 295 |
+
# Validate roles
|
| 296 |
+
roles = {c.get("role") for c in result["characters"]}
|
| 297 |
+
if "defendant" not in roles:
|
| 298 |
+
raise ValueError("No defendant character found")
|
| 299 |
+
if "witness" not in roles:
|
| 300 |
+
raise ValueError("No witness character found")
|
| 301 |
+
|
| 302 |
+
return result
|
| 303 |
+
|
| 304 |
+
def _generate_testimonies(
|
| 305 |
+
self,
|
| 306 |
+
narrative: dict[str, Any],
|
| 307 |
+
chars_evidence: dict[str, Any],
|
| 308 |
+
difficulty: str,
|
| 309 |
+
) -> dict[str, Any]:
|
| 310 |
+
prompt = TESTIMONY_PROMPT.format(
|
| 311 |
+
narrative_json=json.dumps(narrative, indent=2),
|
| 312 |
+
characters_json=json.dumps(chars_evidence["characters"], indent=2),
|
| 313 |
+
evidence_json=json.dumps(chars_evidence["evidence"], indent=2),
|
| 314 |
+
difficulty=difficulty,
|
| 315 |
+
)
|
| 316 |
+
result = self._call_llm_json(prompt)
|
| 317 |
+
|
| 318 |
+
if "court" not in result:
|
| 319 |
+
raise ValueError("Response missing 'court'")
|
| 320 |
+
court = result["court"]
|
| 321 |
+
if "rounds" not in court or not court["rounds"]:
|
| 322 |
+
raise ValueError("Court must have at least one round")
|
| 323 |
+
if "win_condition" not in court:
|
| 324 |
+
raise ValueError("Court must have a win_condition")
|
| 325 |
+
|
| 326 |
+
return result
|
| 327 |
+
|
| 328 |
+
def _generate_investigation(
|
| 329 |
+
self,
|
| 330 |
+
narrative: dict[str, Any],
|
| 331 |
+
chars_evidence: dict[str, Any],
|
| 332 |
+
) -> dict[str, Any]:
|
| 333 |
+
prompt = INVESTIGATION_PROMPT.format(
|
| 334 |
+
narrative_json=json.dumps(narrative, indent=2),
|
| 335 |
+
characters_json=json.dumps(chars_evidence["characters"], indent=2),
|
| 336 |
+
evidence_json=json.dumps(chars_evidence["evidence"], indent=2),
|
| 337 |
+
)
|
| 338 |
+
result = self._call_llm_json(prompt)
|
| 339 |
+
|
| 340 |
+
if "locations" not in result:
|
| 341 |
+
raise ValueError("Response missing 'locations'")
|
| 342 |
+
if not result["locations"]:
|
| 343 |
+
raise ValueError("Must have at least one location")
|
| 344 |
+
|
| 345 |
+
return result
|
| 346 |
+
|
| 347 |
+
# ------------------------------------------------------------------
|
| 348 |
+
# Assembly
|
| 349 |
+
# ------------------------------------------------------------------
|
| 350 |
+
|
| 351 |
+
def _assemble_case(
|
| 352 |
+
self,
|
| 353 |
+
narrative: dict[str, Any],
|
| 354 |
+
chars_evidence: dict[str, Any],
|
| 355 |
+
court: dict[str, Any],
|
| 356 |
+
investigation: dict[str, Any] | None,
|
| 357 |
+
difficulty: str,
|
| 358 |
+
) -> CaseData:
|
| 359 |
+
"""Combine all generation outputs into a single CaseData."""
|
| 360 |
+
# Build a slug-style ID from the title
|
| 361 |
+
title = narrative.get("title", "Untitled Case")
|
| 362 |
+
case_id = re.sub(r"[^a-z0-9]+", "_", title.lower()).strip("_")
|
| 363 |
+
|
| 364 |
+
case_dict: dict[str, Any] = {
|
| 365 |
+
"id": case_id,
|
| 366 |
+
"title": title,
|
| 367 |
+
"difficulty": difficulty,
|
| 368 |
+
"description": narrative.get("description", ""),
|
| 369 |
+
"metadata": {
|
| 370 |
+
"author": "llm_generated",
|
| 371 |
+
"version": "1.0",
|
| 372 |
+
"tags": [narrative.get("crime_type", "crime")],
|
| 373 |
+
},
|
| 374 |
+
"characters": chars_evidence["characters"],
|
| 375 |
+
"evidence": chars_evidence["evidence"],
|
| 376 |
+
"court": court["court"],
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
if investigation:
|
| 380 |
+
case_dict["locations"] = investigation.get("locations", [])
|
| 381 |
+
case_dict["dialogues"] = investigation.get("dialogues", [])
|
| 382 |
+
case_dict["investigation_gate"] = investigation.get(
|
| 383 |
+
"investigation_gate"
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
return CaseData(**case_dict)
|
turnabout/generation/prompts.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt templates for multi-step LLM case generation.
|
| 2 |
+
|
| 3 |
+
Each prompt targets a specific generation step and includes structure
|
| 4 |
+
requirements plus the expected JSON output schema.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
# ------------------------------------------------------------------
|
| 10 |
+
# Step 1: Narrative generation
|
| 11 |
+
# ------------------------------------------------------------------
|
| 12 |
+
|
| 13 |
+
NARRATIVE_PROMPT = """\
|
| 14 |
+
You are a writer for an Ace Attorney-style courtroom drama game. Generate a \
|
| 15 |
+
compelling crime narrative for a case.
|
| 16 |
+
|
| 17 |
+
Theme: {theme}
|
| 18 |
+
Difficulty: {difficulty}
|
| 19 |
+
|
| 20 |
+
Requirements:
|
| 21 |
+
- A clear crime (theft, fraud, assault, arson, etc. — NOT murder if possible for variety)
|
| 22 |
+
- A wrongly accused defendant
|
| 23 |
+
- The true culprit who framed the defendant
|
| 24 |
+
- 1-2 key witnesses
|
| 25 |
+
- 2-3 pieces of evidence that contradict witness testimony
|
| 26 |
+
- A logical chain of deductions leading to the truth
|
| 27 |
+
- If difficulty is "hard", include an investigation phase with multiple locations
|
| 28 |
+
|
| 29 |
+
Output ONLY valid JSON with this structure:
|
| 30 |
+
{{
|
| 31 |
+
"title": "The Case Title",
|
| 32 |
+
"description": "2-3 sentence case overview for the player",
|
| 33 |
+
"crime_type": "theft|fraud|assault|arson|other",
|
| 34 |
+
"crime_summary": "Detailed description of what actually happened",
|
| 35 |
+
"defendant": {{
|
| 36 |
+
"name": "Full Name",
|
| 37 |
+
"description": "Character description",
|
| 38 |
+
"why_accused": "Why they appear guilty"
|
| 39 |
+
}},
|
| 40 |
+
"true_culprit": {{
|
| 41 |
+
"name": "Full Name",
|
| 42 |
+
"description": "Character description",
|
| 43 |
+
"motive": "Why they did it",
|
| 44 |
+
"method": "How they did it and framed the defendant"
|
| 45 |
+
}},
|
| 46 |
+
"witnesses": [
|
| 47 |
+
{{
|
| 48 |
+
"name": "Full Name",
|
| 49 |
+
"description": "Character description",
|
| 50 |
+
"role_in_case": "What they saw or claim to have seen",
|
| 51 |
+
"lie_or_mistake": "What they get wrong in testimony (and why)"
|
| 52 |
+
}}
|
| 53 |
+
],
|
| 54 |
+
"key_evidence_concepts": [
|
| 55 |
+
{{
|
| 56 |
+
"name": "Evidence Name",
|
| 57 |
+
"description": "What this evidence is",
|
| 58 |
+
"why_important": "How it helps prove the truth"
|
| 59 |
+
}}
|
| 60 |
+
],
|
| 61 |
+
"locations": [
|
| 62 |
+
{{
|
| 63 |
+
"name": "Location Name",
|
| 64 |
+
"description": "Location description",
|
| 65 |
+
"what_can_be_found": "Evidence or clues available here"
|
| 66 |
+
}}
|
| 67 |
+
]
|
| 68 |
+
}}
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
# ------------------------------------------------------------------
|
| 72 |
+
# Step 2: Characters and evidence generation
|
| 73 |
+
# ------------------------------------------------------------------
|
| 74 |
+
|
| 75 |
+
CHARACTERS_EVIDENCE_PROMPT = """\
|
| 76 |
+
You are building an Ace Attorney-style case. Given the narrative below, \
|
| 77 |
+
generate the full characters and evidence lists.
|
| 78 |
+
|
| 79 |
+
NARRATIVE:
|
| 80 |
+
{narrative_json}
|
| 81 |
+
|
| 82 |
+
DIFFICULTY: {difficulty}
|
| 83 |
+
|
| 84 |
+
Requirements for characters:
|
| 85 |
+
- Each character needs: id (snake_case), name, description, role
|
| 86 |
+
- Roles: "defendant", "witness", "npc", "prosecutor", "judge"
|
| 87 |
+
- Must include exactly one defendant and at least one witness
|
| 88 |
+
- Include a judge character
|
| 89 |
+
|
| 90 |
+
Requirements for evidence:
|
| 91 |
+
- Each evidence needs: id (snake_case), name, item_type, description, detail, source
|
| 92 |
+
- item_type: "document" | "physical" | "photo" | "testimony_record"
|
| 93 |
+
- source: "pre_given" (player starts with it) | "investigation" (must be found)
|
| 94 |
+
- pre_given evidence: set acquisition to null
|
| 95 |
+
- investigation evidence: must have acquisition with:
|
| 96 |
+
- method: "examine" | "talk" | "present"
|
| 97 |
+
- location_id: where to find it (snake_case)
|
| 98 |
+
- target_id: what to examine or who to talk to (snake_case)
|
| 99 |
+
- prerequisites: list of evidence IDs needed first (can be empty)
|
| 100 |
+
- present_evidence_id: if method is "present", which evidence to show (else null)
|
| 101 |
+
- At least 2 pieces of pre_given evidence
|
| 102 |
+
- Each investigation evidence must be obtainable (no impossible prerequisites)
|
| 103 |
+
|
| 104 |
+
Output ONLY valid JSON with this structure:
|
| 105 |
+
{{
|
| 106 |
+
"characters": [
|
| 107 |
+
{{
|
| 108 |
+
"id": "character_id",
|
| 109 |
+
"name": "Full Name",
|
| 110 |
+
"description": "Description",
|
| 111 |
+
"role": "defendant|witness|npc|prosecutor|judge"
|
| 112 |
+
}}
|
| 113 |
+
],
|
| 114 |
+
"evidence": [
|
| 115 |
+
{{
|
| 116 |
+
"id": "evidence_id",
|
| 117 |
+
"name": "Evidence Name",
|
| 118 |
+
"item_type": "document|physical|photo|testimony_record",
|
| 119 |
+
"description": "Short description shown in court record",
|
| 120 |
+
"detail": "Detailed text revealed when examining the evidence",
|
| 121 |
+
"source": "pre_given|investigation",
|
| 122 |
+
"acquisition": null | {{
|
| 123 |
+
"method": "examine|talk|present",
|
| 124 |
+
"location_id": "location_id",
|
| 125 |
+
"target_id": "target_id",
|
| 126 |
+
"prerequisites": [],
|
| 127 |
+
"present_evidence_id": null | "evidence_id"
|
| 128 |
+
}}
|
| 129 |
+
}}
|
| 130 |
+
]
|
| 131 |
+
}}
|
| 132 |
+
"""
|
| 133 |
+
|
| 134 |
+
# ------------------------------------------------------------------
|
| 135 |
+
# Step 3: Testimony and contradiction generation
|
| 136 |
+
# ------------------------------------------------------------------
|
| 137 |
+
|
| 138 |
+
TESTIMONY_PROMPT = """\
|
| 139 |
+
You are building an Ace Attorney-style case. Given the narrative and evidence \
|
| 140 |
+
below, generate witness testimonies with contradictions.
|
| 141 |
+
|
| 142 |
+
NARRATIVE:
|
| 143 |
+
{narrative_json}
|
| 144 |
+
|
| 145 |
+
CHARACTERS:
|
| 146 |
+
{characters_json}
|
| 147 |
+
|
| 148 |
+
EVIDENCE:
|
| 149 |
+
{evidence_json}
|
| 150 |
+
|
| 151 |
+
DIFFICULTY: {difficulty}
|
| 152 |
+
|
| 153 |
+
Requirements for testimonies:
|
| 154 |
+
- Each CourtRound has one witness and multiple testimonies
|
| 155 |
+
- The initial testimony is what the witness first says
|
| 156 |
+
- Contradictions in the initial testimony trigger new testimonies
|
| 157 |
+
- Each testimony has 3-5 statements
|
| 158 |
+
|
| 159 |
+
Requirements for statements:
|
| 160 |
+
- id: unique snake_case identifier
|
| 161 |
+
- text: what the witness says
|
| 162 |
+
- press_response: what they say when pressed (HOLD IT!)
|
| 163 |
+
- press_reveals_statement: optional statement ID revealed by pressing
|
| 164 |
+
- press_grants_evidence: optional evidence ID obtained by pressing
|
| 165 |
+
- contradiction: null OR {{evidence_id, explanation, is_primary, triggers_testimony}}
|
| 166 |
+
|
| 167 |
+
Requirements for contradictions:
|
| 168 |
+
- evidence_id must match an existing evidence item
|
| 169 |
+
- explanation: dramatic OBJECTION text explaining why the evidence contradicts
|
| 170 |
+
- is_primary: true if this is a required contradiction for winning
|
| 171 |
+
- triggers_testimony: testimony ID of the next testimony (or null if final)
|
| 172 |
+
|
| 173 |
+
Requirements for win_condition:
|
| 174 |
+
- required_contradiction_ids: list of statement IDs the player MUST contradict
|
| 175 |
+
- final_evidence_id: the most important evidence (optional)
|
| 176 |
+
|
| 177 |
+
The testimony chain must be logically solvable. The player needs to find \
|
| 178 |
+
contradictions using the provided evidence.
|
| 179 |
+
|
| 180 |
+
Output ONLY valid JSON with this structure:
|
| 181 |
+
{{
|
| 182 |
+
"court": {{
|
| 183 |
+
"penalty_limit": 5,
|
| 184 |
+
"rounds": [
|
| 185 |
+
{{
|
| 186 |
+
"witness_id": "witness_character_id",
|
| 187 |
+
"order": 0,
|
| 188 |
+
"initial_testimony_id": "testimony_id",
|
| 189 |
+
"testimonies": [
|
| 190 |
+
{{
|
| 191 |
+
"id": "testimony_id",
|
| 192 |
+
"title": "Testimony Title",
|
| 193 |
+
"witness_id": "witness_character_id",
|
| 194 |
+
"preamble": "Judge's introduction of the testimony",
|
| 195 |
+
"statements": [
|
| 196 |
+
{{
|
| 197 |
+
"id": "statement_id",
|
| 198 |
+
"text": "What the witness says",
|
| 199 |
+
"press_response": "Response when pressed",
|
| 200 |
+
"press_reveals_statement": null,
|
| 201 |
+
"press_grants_evidence": null,
|
| 202 |
+
"contradiction": null | {{
|
| 203 |
+
"evidence_id": "evidence_id",
|
| 204 |
+
"explanation": "OBJECTION! ...",
|
| 205 |
+
"is_primary": true,
|
| 206 |
+
"triggers_testimony": null | "next_testimony_id"
|
| 207 |
+
}}
|
| 208 |
+
}}
|
| 209 |
+
]
|
| 210 |
+
}}
|
| 211 |
+
]
|
| 212 |
+
}}
|
| 213 |
+
],
|
| 214 |
+
"win_condition": {{
|
| 215 |
+
"required_contradiction_ids": ["statement_id_1", "statement_id_2"],
|
| 216 |
+
"final_evidence_id": "evidence_id" | null
|
| 217 |
+
}}
|
| 218 |
+
}}
|
| 219 |
+
}}
|
| 220 |
+
"""
|
| 221 |
+
|
| 222 |
+
# ------------------------------------------------------------------
|
| 223 |
+
# Step 4: Investigation phase generation (hard mode)
|
| 224 |
+
# ------------------------------------------------------------------
|
| 225 |
+
|
| 226 |
+
INVESTIGATION_PROMPT = """\
|
| 227 |
+
You are building the investigation phase of an Ace Attorney-style case. \
|
| 228 |
+
Given the narrative, characters, and evidence, generate locations and dialogues.
|
| 229 |
+
|
| 230 |
+
NARRATIVE:
|
| 231 |
+
{narrative_json}
|
| 232 |
+
|
| 233 |
+
CHARACTERS:
|
| 234 |
+
{characters_json}
|
| 235 |
+
|
| 236 |
+
EVIDENCE:
|
| 237 |
+
{evidence_json}
|
| 238 |
+
|
| 239 |
+
Requirements for locations:
|
| 240 |
+
- Each location has: id (snake_case), name, description, connected_to, \
|
| 241 |
+
examinables, characters_present, available_from_start, unlock_prerequisites
|
| 242 |
+
- connected_to: list of other location IDs (bidirectional connections)
|
| 243 |
+
- examinables: objects the player can examine at this location
|
| 244 |
+
- characters_present: character IDs found here
|
| 245 |
+
- available_from_start: whether the player can visit immediately
|
| 246 |
+
- unlock_prerequisites: evidence IDs needed to unlock (if not available from start)
|
| 247 |
+
- At least 2 locations available from start
|
| 248 |
+
- All locations must be connected (reachable via connections)
|
| 249 |
+
- Locked locations must have obtainable prerequisites
|
| 250 |
+
|
| 251 |
+
Requirements for examinables:
|
| 252 |
+
- id, name, description, examined_description (optional detailed text)
|
| 253 |
+
- Must include examinables that yield investigation evidence (matching \
|
| 254 |
+
evidence acquisition target_ids)
|
| 255 |
+
|
| 256 |
+
Requirements for dialogues:
|
| 257 |
+
- Each dialogue tree is for one character
|
| 258 |
+
- Lines: things the character says (with optional prerequisites and grants_evidence)
|
| 259 |
+
- present_responses: how the character reacts when shown specific evidence
|
| 260 |
+
|
| 261 |
+
Requirements for investigation_gate:
|
| 262 |
+
- required_evidence: evidence IDs the player needs before going to court
|
| 263 |
+
- auto_advance: false (player chooses when to go to court)
|
| 264 |
+
|
| 265 |
+
Ensure all evidence acquisition target_ids and location_ids match the \
|
| 266 |
+
locations and examinables you generate.
|
| 267 |
+
|
| 268 |
+
Output ONLY valid JSON with this structure:
|
| 269 |
+
{{
|
| 270 |
+
"locations": [
|
| 271 |
+
{{
|
| 272 |
+
"id": "location_id",
|
| 273 |
+
"name": "Location Name",
|
| 274 |
+
"description": "Location description",
|
| 275 |
+
"connected_to": ["other_location_id"],
|
| 276 |
+
"examinables": [
|
| 277 |
+
{{
|
| 278 |
+
"id": "examinable_id",
|
| 279 |
+
"name": "Object Name",
|
| 280 |
+
"description": "Brief description",
|
| 281 |
+
"examined_description": "Detailed description when examined"
|
| 282 |
+
}}
|
| 283 |
+
],
|
| 284 |
+
"characters_present": ["character_id"],
|
| 285 |
+
"available_from_start": true,
|
| 286 |
+
"unlock_prerequisites": []
|
| 287 |
+
}}
|
| 288 |
+
],
|
| 289 |
+
"dialogues": [
|
| 290 |
+
{{
|
| 291 |
+
"character_id": "character_id",
|
| 292 |
+
"lines": [
|
| 293 |
+
{{
|
| 294 |
+
"id": "line_id",
|
| 295 |
+
"text": "What they say",
|
| 296 |
+
"prerequisites": [],
|
| 297 |
+
"grants_evidence": null | "evidence_id"
|
| 298 |
+
}}
|
| 299 |
+
],
|
| 300 |
+
"present_responses": [
|
| 301 |
+
{{
|
| 302 |
+
"evidence_id": "evidence_id",
|
| 303 |
+
"text": "Response when shown this evidence",
|
| 304 |
+
"grants_evidence": null | "evidence_id",
|
| 305 |
+
"unlocks_dialogue_line": null | "line_id"
|
| 306 |
+
}}
|
| 307 |
+
]
|
| 308 |
+
}}
|
| 309 |
+
],
|
| 310 |
+
"investigation_gate": {{
|
| 311 |
+
"required_evidence": ["evidence_id_1", "evidence_id_2"],
|
| 312 |
+
"auto_advance": false
|
| 313 |
+
}}
|
| 314 |
+
}}
|
| 315 |
+
"""
|
turnabout/generation/validator.py
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Case data validation with multi-level checks.
|
| 2 |
+
|
| 3 |
+
Validates structural integrity, reference consistency, evidence reachability,
|
| 4 |
+
contradiction validity, solvability, and deadlock detection.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from collections import deque
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
|
| 12 |
+
from turnabout.core.schema import CaseData
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass
|
| 16 |
+
class ValidationReport:
|
| 17 |
+
"""Result of validating a CaseData instance."""
|
| 18 |
+
|
| 19 |
+
is_valid: bool = True
|
| 20 |
+
errors: list[str] = field(default_factory=list) # Must fix
|
| 21 |
+
warnings: list[str] = field(default_factory=list) # Should fix
|
| 22 |
+
|
| 23 |
+
def add_error(self, msg: str) -> None:
|
| 24 |
+
self.errors.append(msg)
|
| 25 |
+
self.is_valid = False
|
| 26 |
+
|
| 27 |
+
def add_warning(self, msg: str) -> None:
|
| 28 |
+
self.warnings.append(msg)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class CaseValidator:
|
| 32 |
+
"""Validates a CaseData instance across six dimensions."""
|
| 33 |
+
|
| 34 |
+
def validate(self, case: CaseData) -> ValidationReport:
|
| 35 |
+
"""Run all validations and return a consolidated report."""
|
| 36 |
+
report = ValidationReport()
|
| 37 |
+
|
| 38 |
+
for error in self._validate_references(case):
|
| 39 |
+
report.add_error(error)
|
| 40 |
+
|
| 41 |
+
for error in self._validate_contradictions(case):
|
| 42 |
+
report.add_error(error)
|
| 43 |
+
|
| 44 |
+
for error in self._validate_solvability(case):
|
| 45 |
+
report.add_error(error)
|
| 46 |
+
|
| 47 |
+
for error in self._validate_no_deadlocks(case):
|
| 48 |
+
report.add_error(error)
|
| 49 |
+
|
| 50 |
+
for error in self._validate_reachability(case):
|
| 51 |
+
report.add_error(error)
|
| 52 |
+
|
| 53 |
+
# Structural warnings (non-blocking)
|
| 54 |
+
for warning in self._validate_structure(case):
|
| 55 |
+
report.add_warning(warning)
|
| 56 |
+
|
| 57 |
+
return report
|
| 58 |
+
|
| 59 |
+
# ------------------------------------------------------------------
|
| 60 |
+
# Level 1: Reference integrity
|
| 61 |
+
# ------------------------------------------------------------------
|
| 62 |
+
|
| 63 |
+
def _validate_references(self, case: CaseData) -> list[str]:
|
| 64 |
+
"""All IDs referenced must exist (evidence IDs, location IDs,
|
| 65 |
+
character IDs, statement IDs, testimony IDs, etc.)."""
|
| 66 |
+
errors: list[str] = []
|
| 67 |
+
|
| 68 |
+
char_ids = {c.id for c in case.characters}
|
| 69 |
+
ev_ids = case.get_all_evidence_ids()
|
| 70 |
+
loc_ids = {loc.id for loc in case.locations}
|
| 71 |
+
testimony_ids: set[str] = set()
|
| 72 |
+
statement_ids: set[str] = set()
|
| 73 |
+
examinable_ids: set[str] = set()
|
| 74 |
+
|
| 75 |
+
for loc in case.locations:
|
| 76 |
+
for ex in loc.examinables:
|
| 77 |
+
examinable_ids.add(ex.id)
|
| 78 |
+
|
| 79 |
+
for rnd in case.court.rounds:
|
| 80 |
+
for t in rnd.testimonies:
|
| 81 |
+
testimony_ids.add(t.id)
|
| 82 |
+
for s in t.statements:
|
| 83 |
+
statement_ids.add(s.id)
|
| 84 |
+
|
| 85 |
+
# Location connected_to
|
| 86 |
+
for loc in case.locations:
|
| 87 |
+
for cid in loc.connected_to:
|
| 88 |
+
if cid not in loc_ids:
|
| 89 |
+
errors.append(
|
| 90 |
+
f"Location '{loc.id}' connects to unknown location '{cid}'"
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# Characters present in locations
|
| 94 |
+
for loc in case.locations:
|
| 95 |
+
for cid in loc.characters_present:
|
| 96 |
+
if cid not in char_ids:
|
| 97 |
+
errors.append(
|
| 98 |
+
f"Location '{loc.id}' references unknown character '{cid}'"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Evidence acquisition references
|
| 102 |
+
for ev in case.evidence:
|
| 103 |
+
if ev.acquisition:
|
| 104 |
+
acq = ev.acquisition
|
| 105 |
+
if acq.location_id and acq.location_id not in loc_ids:
|
| 106 |
+
errors.append(
|
| 107 |
+
f"Evidence '{ev.id}' acquisition references unknown "
|
| 108 |
+
f"location '{acq.location_id}'"
|
| 109 |
+
)
|
| 110 |
+
# target_id is either an examinable or a character
|
| 111 |
+
if acq.method == "examine":
|
| 112 |
+
if acq.target_id not in examinable_ids:
|
| 113 |
+
errors.append(
|
| 114 |
+
f"Evidence '{ev.id}' acquisition references unknown "
|
| 115 |
+
f"examinable '{acq.target_id}'"
|
| 116 |
+
)
|
| 117 |
+
elif acq.method in ("talk", "present"):
|
| 118 |
+
if acq.target_id not in char_ids:
|
| 119 |
+
errors.append(
|
| 120 |
+
f"Evidence '{ev.id}' acquisition references unknown "
|
| 121 |
+
f"character '{acq.target_id}'"
|
| 122 |
+
)
|
| 123 |
+
# present_evidence_id
|
| 124 |
+
if acq.present_evidence_id and acq.present_evidence_id not in ev_ids:
|
| 125 |
+
errors.append(
|
| 126 |
+
f"Evidence '{ev.id}' acquisition references unknown "
|
| 127 |
+
f"present_evidence_id '{acq.present_evidence_id}'"
|
| 128 |
+
)
|
| 129 |
+
# prerequisites are evidence IDs
|
| 130 |
+
for prereq in acq.prerequisites:
|
| 131 |
+
if prereq not in ev_ids:
|
| 132 |
+
errors.append(
|
| 133 |
+
f"Evidence '{ev.id}' acquisition prerequisite "
|
| 134 |
+
f"'{prereq}' is not a known evidence ID"
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# Dialogue character references
|
| 138 |
+
for dlg in case.dialogues:
|
| 139 |
+
if dlg.character_id not in char_ids:
|
| 140 |
+
errors.append(
|
| 141 |
+
f"Dialogue references unknown character '{dlg.character_id}'"
|
| 142 |
+
)
|
| 143 |
+
for line in dlg.lines:
|
| 144 |
+
if line.grants_evidence and line.grants_evidence not in ev_ids:
|
| 145 |
+
errors.append(
|
| 146 |
+
f"Dialogue line '{line.id}' grants unknown evidence "
|
| 147 |
+
f"'{line.grants_evidence}'"
|
| 148 |
+
)
|
| 149 |
+
for prereq in line.prerequisites:
|
| 150 |
+
if prereq not in ev_ids:
|
| 151 |
+
errors.append(
|
| 152 |
+
f"Dialogue line '{line.id}' prerequisite "
|
| 153 |
+
f"'{prereq}' is not a known evidence ID"
|
| 154 |
+
)
|
| 155 |
+
for pr in dlg.present_responses:
|
| 156 |
+
if pr.evidence_id not in ev_ids:
|
| 157 |
+
errors.append(
|
| 158 |
+
f"Present response references unknown evidence "
|
| 159 |
+
f"'{pr.evidence_id}'"
|
| 160 |
+
)
|
| 161 |
+
if pr.grants_evidence and pr.grants_evidence not in ev_ids:
|
| 162 |
+
errors.append(
|
| 163 |
+
f"Present response grants unknown evidence "
|
| 164 |
+
f"'{pr.grants_evidence}'"
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# Court round references
|
| 168 |
+
for rnd in case.court.rounds:
|
| 169 |
+
if rnd.witness_id not in char_ids:
|
| 170 |
+
errors.append(
|
| 171 |
+
f"Court round references unknown witness '{rnd.witness_id}'"
|
| 172 |
+
)
|
| 173 |
+
if rnd.initial_testimony_id not in testimony_ids:
|
| 174 |
+
errors.append(
|
| 175 |
+
f"Court round initial_testimony_id "
|
| 176 |
+
f"'{rnd.initial_testimony_id}' not found"
|
| 177 |
+
)
|
| 178 |
+
for t in rnd.testimonies:
|
| 179 |
+
if t.witness_id not in char_ids:
|
| 180 |
+
errors.append(
|
| 181 |
+
f"Testimony '{t.id}' references unknown witness "
|
| 182 |
+
f"'{t.witness_id}'"
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
# Statement press_reveals_statement
|
| 186 |
+
for rnd in case.court.rounds:
|
| 187 |
+
for t in rnd.testimonies:
|
| 188 |
+
for s in t.statements:
|
| 189 |
+
if (
|
| 190 |
+
s.press_reveals_statement
|
| 191 |
+
and s.press_reveals_statement not in statement_ids
|
| 192 |
+
):
|
| 193 |
+
errors.append(
|
| 194 |
+
f"Statement '{s.id}' press_reveals_statement "
|
| 195 |
+
f"'{s.press_reveals_statement}' not found"
|
| 196 |
+
)
|
| 197 |
+
if s.press_grants_evidence and s.press_grants_evidence not in ev_ids:
|
| 198 |
+
errors.append(
|
| 199 |
+
f"Statement '{s.id}' press_grants_evidence "
|
| 200 |
+
f"'{s.press_grants_evidence}' not found"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Contradiction triggers_testimony
|
| 204 |
+
for rnd in case.court.rounds:
|
| 205 |
+
for t in rnd.testimonies:
|
| 206 |
+
for s in t.statements:
|
| 207 |
+
if s.contradiction and s.contradiction.triggers_testimony:
|
| 208 |
+
if s.contradiction.triggers_testimony not in testimony_ids:
|
| 209 |
+
errors.append(
|
| 210 |
+
f"Statement '{s.id}' contradiction "
|
| 211 |
+
f"triggers_testimony "
|
| 212 |
+
f"'{s.contradiction.triggers_testimony}' not found"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
# Win condition statement IDs
|
| 216 |
+
for sid in case.court.win_condition.required_contradiction_ids:
|
| 217 |
+
if sid not in statement_ids:
|
| 218 |
+
errors.append(
|
| 219 |
+
f"Win condition references unknown statement '{sid}'"
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
# Win condition final_evidence_id
|
| 223 |
+
if (
|
| 224 |
+
case.court.win_condition.final_evidence_id
|
| 225 |
+
and case.court.win_condition.final_evidence_id not in ev_ids
|
| 226 |
+
):
|
| 227 |
+
errors.append(
|
| 228 |
+
f"Win condition final_evidence_id "
|
| 229 |
+
f"'{case.court.win_condition.final_evidence_id}' not found"
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
# Investigation gate evidence IDs
|
| 233 |
+
if case.investigation_gate:
|
| 234 |
+
for eid in case.investigation_gate.required_evidence:
|
| 235 |
+
if eid not in ev_ids:
|
| 236 |
+
errors.append(
|
| 237 |
+
f"Investigation gate references unknown evidence '{eid}'"
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
# Location unlock_prerequisites are evidence IDs
|
| 241 |
+
for loc in case.locations:
|
| 242 |
+
for prereq in loc.unlock_prerequisites:
|
| 243 |
+
if prereq not in ev_ids:
|
| 244 |
+
errors.append(
|
| 245 |
+
f"Location '{loc.id}' unlock_prerequisite "
|
| 246 |
+
f"'{prereq}' is not a known evidence ID"
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
return errors
|
| 250 |
+
|
| 251 |
+
# ------------------------------------------------------------------
|
| 252 |
+
# Level 2: Evidence reachability (BFS from initial state)
|
| 253 |
+
# ------------------------------------------------------------------
|
| 254 |
+
|
| 255 |
+
def _validate_reachability(self, case: CaseData) -> list[str]:
|
| 256 |
+
"""All required evidence can be obtained via BFS from initial state.
|
| 257 |
+
|
| 258 |
+
Simulates the investigation phase: starting with pre_given evidence
|
| 259 |
+
and available_from_start locations, iteratively collect evidence and
|
| 260 |
+
unlock locations until no more progress can be made.
|
| 261 |
+
"""
|
| 262 |
+
errors: list[str] = []
|
| 263 |
+
|
| 264 |
+
# If no locations defined (easy-mode only case), skip
|
| 265 |
+
if not case.locations:
|
| 266 |
+
return errors
|
| 267 |
+
|
| 268 |
+
inventory = {e.id for e in case.get_pre_given_evidence()}
|
| 269 |
+
unlocked = {loc.id for loc in case.locations if loc.available_from_start}
|
| 270 |
+
|
| 271 |
+
# Iterative fixed-point: keep collecting until nothing new
|
| 272 |
+
changed = True
|
| 273 |
+
while changed:
|
| 274 |
+
changed = False
|
| 275 |
+
|
| 276 |
+
# Try to unlock new locations
|
| 277 |
+
for loc in case.locations:
|
| 278 |
+
if loc.id not in unlocked and not loc.available_from_start:
|
| 279 |
+
if all(p in inventory for p in loc.unlock_prerequisites):
|
| 280 |
+
unlocked.add(loc.id)
|
| 281 |
+
changed = True
|
| 282 |
+
|
| 283 |
+
# Try to obtain new evidence from unlocked locations
|
| 284 |
+
for ev in case.evidence:
|
| 285 |
+
if ev.id in inventory:
|
| 286 |
+
continue
|
| 287 |
+
if ev.source == "pre_given":
|
| 288 |
+
continue
|
| 289 |
+
if not ev.acquisition:
|
| 290 |
+
continue
|
| 291 |
+
acq = ev.acquisition
|
| 292 |
+
if acq.location_id not in unlocked:
|
| 293 |
+
continue
|
| 294 |
+
if not all(p in inventory for p in acq.prerequisites):
|
| 295 |
+
continue
|
| 296 |
+
if acq.method == "present" and acq.present_evidence_id:
|
| 297 |
+
if acq.present_evidence_id not in inventory:
|
| 298 |
+
continue
|
| 299 |
+
# Verify that the target actually exists at the location
|
| 300 |
+
loc = case.get_location(acq.location_id)
|
| 301 |
+
if loc:
|
| 302 |
+
if acq.method == "examine":
|
| 303 |
+
if not any(
|
| 304 |
+
ex.id == acq.target_id for ex in loc.examinables
|
| 305 |
+
):
|
| 306 |
+
continue
|
| 307 |
+
elif acq.method in ("talk", "present"):
|
| 308 |
+
if acq.target_id not in loc.characters_present:
|
| 309 |
+
continue
|
| 310 |
+
inventory.add(ev.id)
|
| 311 |
+
changed = True
|
| 312 |
+
|
| 313 |
+
# Try to obtain evidence from dialogue lines
|
| 314 |
+
for dlg in case.dialogues:
|
| 315 |
+
# Check if the character's location is unlocked
|
| 316 |
+
char_locations = [
|
| 317 |
+
loc for loc in case.locations
|
| 318 |
+
if dlg.character_id in loc.characters_present
|
| 319 |
+
and loc.id in unlocked
|
| 320 |
+
]
|
| 321 |
+
if not char_locations:
|
| 322 |
+
continue
|
| 323 |
+
for line in dlg.lines:
|
| 324 |
+
if line.grants_evidence and line.grants_evidence not in inventory:
|
| 325 |
+
if all(p in inventory for p in line.prerequisites):
|
| 326 |
+
inventory.add(line.grants_evidence)
|
| 327 |
+
changed = True
|
| 328 |
+
# Present responses
|
| 329 |
+
for pr in dlg.present_responses:
|
| 330 |
+
if pr.grants_evidence and pr.grants_evidence not in inventory:
|
| 331 |
+
if pr.evidence_id in inventory:
|
| 332 |
+
inventory.add(pr.grants_evidence)
|
| 333 |
+
changed = True
|
| 334 |
+
|
| 335 |
+
# Check investigation gate requirements
|
| 336 |
+
if case.investigation_gate:
|
| 337 |
+
for eid in case.investigation_gate.required_evidence:
|
| 338 |
+
if eid not in inventory:
|
| 339 |
+
errors.append(
|
| 340 |
+
f"Required evidence '{eid}' is unreachable from "
|
| 341 |
+
f"initial state"
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
# Check that all investigation evidence is reachable
|
| 345 |
+
for ev in case.evidence:
|
| 346 |
+
if ev.source == "investigation" and ev.id not in inventory:
|
| 347 |
+
errors.append(
|
| 348 |
+
f"Investigation evidence '{ev.id}' is unreachable from "
|
| 349 |
+
f"initial state"
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
return errors
|
| 353 |
+
|
| 354 |
+
# ------------------------------------------------------------------
|
| 355 |
+
# Level 3: Contradiction validity
|
| 356 |
+
# ------------------------------------------------------------------
|
| 357 |
+
|
| 358 |
+
def _validate_contradictions(self, case: CaseData) -> list[str]:
|
| 359 |
+
"""Each contradiction references existing evidence."""
|
| 360 |
+
errors: list[str] = []
|
| 361 |
+
ev_ids = case.get_all_evidence_ids()
|
| 362 |
+
|
| 363 |
+
for rnd in case.court.rounds:
|
| 364 |
+
for t in rnd.testimonies:
|
| 365 |
+
for s in t.statements:
|
| 366 |
+
if s.contradiction:
|
| 367 |
+
if s.contradiction.evidence_id not in ev_ids:
|
| 368 |
+
errors.append(
|
| 369 |
+
f"Statement '{s.id}' contradiction references "
|
| 370 |
+
f"unknown evidence '{s.contradiction.evidence_id}'"
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
return errors
|
| 374 |
+
|
| 375 |
+
# ------------------------------------------------------------------
|
| 376 |
+
# Level 4: Solvability
|
| 377 |
+
# ------------------------------------------------------------------
|
| 378 |
+
|
| 379 |
+
def _validate_solvability(self, case: CaseData) -> list[str]:
|
| 380 |
+
"""Win condition statements all have contradictions defined."""
|
| 381 |
+
errors: list[str] = []
|
| 382 |
+
|
| 383 |
+
for sid in case.court.win_condition.required_contradiction_ids:
|
| 384 |
+
stmt = case.get_statement(sid)
|
| 385 |
+
if stmt is None:
|
| 386 |
+
# Already caught by reference validation
|
| 387 |
+
continue
|
| 388 |
+
if stmt.contradiction is None:
|
| 389 |
+
errors.append(
|
| 390 |
+
f"Win condition requires contradiction on statement "
|
| 391 |
+
f"'{sid}', but it has no contradiction defined"
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
# Check that contradictions leading to required testimonies are
|
| 395 |
+
# reachable (the chain of triggers_testimony must eventually reach
|
| 396 |
+
# all required statements)
|
| 397 |
+
required_ids = set(case.court.win_condition.required_contradiction_ids)
|
| 398 |
+
reachable_testimonies: set[str] = set()
|
| 399 |
+
for rnd in case.court.rounds:
|
| 400 |
+
reachable_testimonies.add(rnd.initial_testimony_id)
|
| 401 |
+
|
| 402 |
+
changed = True
|
| 403 |
+
while changed:
|
| 404 |
+
changed = False
|
| 405 |
+
for rnd in case.court.rounds:
|
| 406 |
+
for t in rnd.testimonies:
|
| 407 |
+
if t.id not in reachable_testimonies:
|
| 408 |
+
continue
|
| 409 |
+
for s in t.statements:
|
| 410 |
+
if (
|
| 411 |
+
s.contradiction
|
| 412 |
+
and s.contradiction.triggers_testimony
|
| 413 |
+
and s.contradiction.triggers_testimony
|
| 414 |
+
not in reachable_testimonies
|
| 415 |
+
):
|
| 416 |
+
reachable_testimonies.add(
|
| 417 |
+
s.contradiction.triggers_testimony
|
| 418 |
+
)
|
| 419 |
+
changed = True
|
| 420 |
+
|
| 421 |
+
for sid in required_ids:
|
| 422 |
+
stmt = case.get_statement(sid)
|
| 423 |
+
if stmt is None:
|
| 424 |
+
continue
|
| 425 |
+
# Find which testimony contains this statement
|
| 426 |
+
for rnd in case.court.rounds:
|
| 427 |
+
for t in rnd.testimonies:
|
| 428 |
+
if any(s.id == sid for s in t.statements):
|
| 429 |
+
if t.id not in reachable_testimonies:
|
| 430 |
+
errors.append(
|
| 431 |
+
f"Win condition statement '{sid}' is in "
|
| 432 |
+
f"testimony '{t.id}' which is unreachable "
|
| 433 |
+
f"(no contradiction triggers it)"
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
return errors
|
| 437 |
+
|
| 438 |
+
# ------------------------------------------------------------------
|
| 439 |
+
# Level 5: Deadlock detection
|
| 440 |
+
# ------------------------------------------------------------------
|
| 441 |
+
|
| 442 |
+
def _validate_no_deadlocks(self, case: CaseData) -> list[str]:
|
| 443 |
+
"""Check for unreachable locations or circular prerequisites."""
|
| 444 |
+
errors: list[str] = []
|
| 445 |
+
|
| 446 |
+
if not case.locations:
|
| 447 |
+
return errors
|
| 448 |
+
|
| 449 |
+
# Check for locations with prerequisites that can never be satisfied
|
| 450 |
+
# (circular dependency detection via topological analysis)
|
| 451 |
+
ev_ids = case.get_all_evidence_ids()
|
| 452 |
+
|
| 453 |
+
# Build a dependency graph for evidence acquisition
|
| 454 |
+
# Evidence A depends on evidence B if B is in A's prerequisites
|
| 455 |
+
# or if B is needed to unlock the location where A is found
|
| 456 |
+
depends_on: dict[str, set[str]] = {}
|
| 457 |
+
for ev in case.evidence:
|
| 458 |
+
deps: set[str] = set()
|
| 459 |
+
if ev.acquisition:
|
| 460 |
+
deps.update(ev.acquisition.prerequisites)
|
| 461 |
+
if ev.acquisition.present_evidence_id:
|
| 462 |
+
deps.add(ev.acquisition.present_evidence_id)
|
| 463 |
+
# If location requires evidence to unlock
|
| 464 |
+
loc = case.get_location(ev.acquisition.location_id)
|
| 465 |
+
if loc and not loc.available_from_start:
|
| 466 |
+
deps.update(loc.unlock_prerequisites)
|
| 467 |
+
depends_on[ev.id] = deps
|
| 468 |
+
|
| 469 |
+
# Check for circular dependencies using DFS
|
| 470 |
+
WHITE, GRAY, BLACK = 0, 1, 2
|
| 471 |
+
color: dict[str, int] = {eid: WHITE for eid in depends_on}
|
| 472 |
+
|
| 473 |
+
def dfs(node: str, path: list[str]) -> str | None:
|
| 474 |
+
color[node] = GRAY
|
| 475 |
+
path.append(node)
|
| 476 |
+
for dep in depends_on.get(node, set()):
|
| 477 |
+
if dep not in color:
|
| 478 |
+
# pre_given evidence, no cycle through here
|
| 479 |
+
continue
|
| 480 |
+
if color[dep] == GRAY:
|
| 481 |
+
cycle_start = path.index(dep)
|
| 482 |
+
cycle = path[cycle_start:] + [dep]
|
| 483 |
+
return " -> ".join(cycle)
|
| 484 |
+
if color[dep] == WHITE:
|
| 485 |
+
result = dfs(dep, path)
|
| 486 |
+
if result:
|
| 487 |
+
return result
|
| 488 |
+
path.pop()
|
| 489 |
+
color[node] = BLACK
|
| 490 |
+
return None
|
| 491 |
+
|
| 492 |
+
for eid in depends_on:
|
| 493 |
+
if color[eid] == WHITE:
|
| 494 |
+
cycle = dfs(eid, [])
|
| 495 |
+
if cycle:
|
| 496 |
+
errors.append(
|
| 497 |
+
f"Circular prerequisite dependency detected: {cycle}"
|
| 498 |
+
)
|
| 499 |
+
break # One cycle report is enough
|
| 500 |
+
|
| 501 |
+
# Check location connectivity: all locations should be reachable
|
| 502 |
+
# from at least one available_from_start location
|
| 503 |
+
start_locs = {loc.id for loc in case.locations if loc.available_from_start}
|
| 504 |
+
if start_locs:
|
| 505 |
+
reachable: set[str] = set()
|
| 506 |
+
queue: deque[str] = deque(start_locs)
|
| 507 |
+
while queue:
|
| 508 |
+
lid = queue.popleft()
|
| 509 |
+
if lid in reachable:
|
| 510 |
+
continue
|
| 511 |
+
reachable.add(lid)
|
| 512 |
+
loc = case.get_location(lid)
|
| 513 |
+
if loc:
|
| 514 |
+
for cid in loc.connected_to:
|
| 515 |
+
if cid not in reachable:
|
| 516 |
+
queue.append(cid)
|
| 517 |
+
|
| 518 |
+
# Locations not reachable even via connections (ignoring prereqs)
|
| 519 |
+
for loc in case.locations:
|
| 520 |
+
if loc.id not in reachable:
|
| 521 |
+
errors.append(
|
| 522 |
+
f"Location '{loc.id}' is not reachable from any "
|
| 523 |
+
f"starting location via connections"
|
| 524 |
+
)
|
| 525 |
+
|
| 526 |
+
return errors
|
| 527 |
+
|
| 528 |
+
# ------------------------------------------------------------------
|
| 529 |
+
# Structural warnings (non-blocking)
|
| 530 |
+
# ------------------------------------------------------------------
|
| 531 |
+
|
| 532 |
+
def _validate_structure(self, case: CaseData) -> list[str]:
|
| 533 |
+
"""Produce warnings for structural issues that are not hard errors."""
|
| 534 |
+
warnings: list[str] = []
|
| 535 |
+
|
| 536 |
+
# Check for defendant character
|
| 537 |
+
defendants = [c for c in case.characters if c.role == "defendant"]
|
| 538 |
+
if not defendants:
|
| 539 |
+
warnings.append("No character with role 'defendant' found")
|
| 540 |
+
|
| 541 |
+
# Check for witness characters
|
| 542 |
+
witnesses = [c for c in case.characters if c.role == "witness"]
|
| 543 |
+
if not witnesses:
|
| 544 |
+
warnings.append("No character with role 'witness' found")
|
| 545 |
+
|
| 546 |
+
# Check for at least one testimony
|
| 547 |
+
total_testimonies = sum(
|
| 548 |
+
len(rnd.testimonies) for rnd in case.court.rounds
|
| 549 |
+
)
|
| 550 |
+
if total_testimonies == 0:
|
| 551 |
+
warnings.append("No testimonies defined in court proceedings")
|
| 552 |
+
|
| 553 |
+
# Hard mode should have locations and dialogues
|
| 554 |
+
if case.difficulty == "hard":
|
| 555 |
+
if not case.locations:
|
| 556 |
+
warnings.append(
|
| 557 |
+
"Hard difficulty case has no locations defined"
|
| 558 |
+
)
|
| 559 |
+
if not case.dialogues:
|
| 560 |
+
warnings.append(
|
| 561 |
+
"Hard difficulty case has no dialogues defined"
|
| 562 |
+
)
|
| 563 |
+
if not case.investigation_gate:
|
| 564 |
+
warnings.append(
|
| 565 |
+
"Hard difficulty case has no investigation gate"
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
+
# Check for empty connected_to lists (isolated locations)
|
| 569 |
+
for loc in case.locations:
|
| 570 |
+
if not loc.connected_to and len(case.locations) > 1:
|
| 571 |
+
warnings.append(
|
| 572 |
+
f"Location '{loc.id}' has no connections to other locations"
|
| 573 |
+
)
|
| 574 |
+
|
| 575 |
+
return warnings
|
turnabout/i18n.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Internationalization — centralized translations for all user-facing strings."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
TRANSLATIONS: dict[str, dict[str, str]] = {
|
| 6 |
+
# ── Engine observations ──
|
| 7 |
+
"move_to": {
|
| 8 |
+
"en": "You move to {name}.\n{desc}",
|
| 9 |
+
"zh": "你前往了{name}。\n{desc}",
|
| 10 |
+
},
|
| 11 |
+
"nothing_to_examine": {
|
| 12 |
+
"en": "Nothing to examine here.",
|
| 13 |
+
"zh": "这里没有什么可以调查的。",
|
| 14 |
+
},
|
| 15 |
+
"new_evidence": {
|
| 16 |
+
"en": "\n\n[New evidence obtained: {name}]",
|
| 17 |
+
"zh": "\n\n【获得新证据:{name}】",
|
| 18 |
+
},
|
| 19 |
+
"nothing_to_say": {
|
| 20 |
+
"en": "{name} has nothing to say.",
|
| 21 |
+
"zh": "{name}无话可说。",
|
| 22 |
+
},
|
| 23 |
+
"nothing_new": {
|
| 24 |
+
"en": "(Nothing new to discuss.)",
|
| 25 |
+
"zh": "(没有新的内容可以讨论。)",
|
| 26 |
+
},
|
| 27 |
+
"nothing_to_say_now": {
|
| 28 |
+
"en": "{name} has nothing to say right now.",
|
| 29 |
+
"zh": "{name}现在没什么可说的。",
|
| 30 |
+
},
|
| 31 |
+
"no_comment": {
|
| 32 |
+
"en": "{char} examines {ev} but has no comment.",
|
| 33 |
+
"zh": "{char}查看了{ev},但没有什么评论。",
|
| 34 |
+
},
|
| 35 |
+
"nothing_useful": {
|
| 36 |
+
"en": "{char} examines {ev} but doesn't have anything useful to say about it.",
|
| 37 |
+
"zh": "{char}查看了{ev},但对此没有什么有用的信息。",
|
| 38 |
+
},
|
| 39 |
+
"court_in_session": {
|
| 40 |
+
"en": "=== COURT IS NOW IN SESSION ===",
|
| 41 |
+
"zh": "=== 开庭审理 ===",
|
| 42 |
+
},
|
| 43 |
+
"witness_label": {
|
| 44 |
+
"en": "Witness: {name}",
|
| 45 |
+
"zh": "证人:{name}",
|
| 46 |
+
},
|
| 47 |
+
"testimony_label": {
|
| 48 |
+
"en": 'Testimony: "{title}"',
|
| 49 |
+
"zh": "证词:「{title}」",
|
| 50 |
+
},
|
| 51 |
+
"cross_exam_header": {
|
| 52 |
+
"en": "=== CROSS-EXAMINATION ===",
|
| 53 |
+
"zh": "=== 质证 ===",
|
| 54 |
+
},
|
| 55 |
+
"press_or_present": {
|
| 56 |
+
"en": "\nYou may press the witness or present evidence.",
|
| 57 |
+
"zh": "\n你可以追问证人或出示证据。",
|
| 58 |
+
},
|
| 59 |
+
"statement_label": {
|
| 60 |
+
"en": '> Statement {num}: "{text}"',
|
| 61 |
+
"zh": '> 第{num}条证言:「{text}」',
|
| 62 |
+
},
|
| 63 |
+
"hold_it": {
|
| 64 |
+
"en": "HOLD IT!",
|
| 65 |
+
"zh": "等一下!",
|
| 66 |
+
},
|
| 67 |
+
"witness_amends": {
|
| 68 |
+
"en": '\n\nThe witness amends their testimony:\n"{text}"',
|
| 69 |
+
"zh": "\n\n证人修改了证词:\n「{text}」",
|
| 70 |
+
},
|
| 71 |
+
"objection": {
|
| 72 |
+
"en": "OBJECTION!",
|
| 73 |
+
"zh": "异议!",
|
| 74 |
+
},
|
| 75 |
+
"new_testimony": {
|
| 76 |
+
"en": "\n\n--- New Testimony ---",
|
| 77 |
+
"zh": "\n\n--- 新证词 ---",
|
| 78 |
+
},
|
| 79 |
+
"not_guilty": {
|
| 80 |
+
"en": "\n\n=== NOT GUILTY ===\nThe defendant has been found NOT GUILTY!",
|
| 81 |
+
"zh": "\n\n=== 无罪 ===\n被告被判定为无罪!",
|
| 82 |
+
},
|
| 83 |
+
"wrong_present": {
|
| 84 |
+
"en": "You present {ev} against this statement, but it doesn't prove anything.\n"
|
| 85 |
+
"Judge: That evidence is irrelevant! Penalty!\n"
|
| 86 |
+
"[Penalties: {cur}/{max}]",
|
| 87 |
+
"zh": "你针对这条证言出示了{ev},但无法证明任何事情。\n"
|
| 88 |
+
"法官:该证据与本案无关!罚分!\n"
|
| 89 |
+
"【罚分:{cur}/{max}】",
|
| 90 |
+
},
|
| 91 |
+
"guilty": {
|
| 92 |
+
"en": "\n\n=== GUILTY ===\nThe defendant has been found GUILTY!",
|
| 93 |
+
"zh": "\n\n=== 有罪 ===\n被告被判定为有罪!",
|
| 94 |
+
},
|
| 95 |
+
"no_current_statement": {
|
| 96 |
+
"en": "No current statement.",
|
| 97 |
+
"zh": "当前没有证言。",
|
| 98 |
+
},
|
| 99 |
+
"statement_not_visible": {
|
| 100 |
+
"en": "That statement is not visible.",
|
| 101 |
+
"zh": "该证言当前不可见。",
|
| 102 |
+
},
|
| 103 |
+
"unknown_action": {
|
| 104 |
+
"en": "Unknown action.",
|
| 105 |
+
"zh": "未知操作。",
|
| 106 |
+
},
|
| 107 |
+
"during_testimony": {
|
| 108 |
+
"en": "During testimony, you can only advance.",
|
| 109 |
+
"zh": "证词陈述中,你只能继续。",
|
| 110 |
+
},
|
| 111 |
+
"invalid_action": {
|
| 112 |
+
"en": "Invalid action: {action}. Valid actions: {valid}",
|
| 113 |
+
"zh": "无效操作:{action}。可用操作:{valid}",
|
| 114 |
+
},
|
| 115 |
+
|
| 116 |
+
# ── Renderer labels ──
|
| 117 |
+
"investigation_header": {
|
| 118 |
+
"en": "=== INVESTIGATION ===",
|
| 119 |
+
"zh": "=== 调查阶段 ===",
|
| 120 |
+
},
|
| 121 |
+
"location_label": {
|
| 122 |
+
"en": "[Location] {name}",
|
| 123 |
+
"zh": "【地点】{name}",
|
| 124 |
+
},
|
| 125 |
+
"people_here": {
|
| 126 |
+
"en": "[People Here] {names}",
|
| 127 |
+
"zh": "【在场人物】{names}",
|
| 128 |
+
},
|
| 129 |
+
"examine_label": {
|
| 130 |
+
"en": "[Examine] {names}",
|
| 131 |
+
"zh": "【可调查】{names}",
|
| 132 |
+
},
|
| 133 |
+
"travel_to": {
|
| 134 |
+
"en": "[Travel To] {names}",
|
| 135 |
+
"zh": "【可前往】{names}",
|
| 136 |
+
},
|
| 137 |
+
"testimony_header": {
|
| 138 |
+
"en": "=== TESTIMONY ===",
|
| 139 |
+
"zh": "=== 证词陈述 ===",
|
| 140 |
+
},
|
| 141 |
+
"court_header": {
|
| 142 |
+
"en": "=== COURT ===",
|
| 143 |
+
"zh": "=== 法庭 ===",
|
| 144 |
+
},
|
| 145 |
+
"penalties_label": {
|
| 146 |
+
"en": "[Penalties: {cur}/{max}]",
|
| 147 |
+
"zh": "【罚分:{cur}/{max}】",
|
| 148 |
+
},
|
| 149 |
+
"actions_hint": {
|
| 150 |
+
"en": "[Actions] press | present <evidence> | next | prev",
|
| 151 |
+
"zh": "【可用操作】追问 | 出示证据 | 下一条 | 上一条",
|
| 152 |
+
},
|
| 153 |
+
"evidence_header": {
|
| 154 |
+
"en": "[Court Record - Evidence]",
|
| 155 |
+
"zh": "【法庭记录 - 证据】",
|
| 156 |
+
},
|
| 157 |
+
"not_guilty_short": {
|
| 158 |
+
"en": "\n=== NOT GUILTY ===",
|
| 159 |
+
"zh": "\n=== 无罪 ===",
|
| 160 |
+
},
|
| 161 |
+
"guilty_short": {
|
| 162 |
+
"en": "\n=== GUILTY ===",
|
| 163 |
+
"zh": "\n=== 有罪 ===",
|
| 164 |
+
},
|
| 165 |
+
|
| 166 |
+
# ── Action display names ──
|
| 167 |
+
"action_move": {"en": "Move to {name}", "zh": "前往 {name}"},
|
| 168 |
+
"action_examine": {"en": "Examine {name}", "zh": "调查 {name}"},
|
| 169 |
+
"action_talk": {"en": "Talk to {name}", "zh": "与{name}对话"},
|
| 170 |
+
"action_press": {"en": "Press (current statement)", "zh": "追问(当前证言)"},
|
| 171 |
+
"action_present_evidence": {"en": "Present {name}", "zh": "出示 {name}"},
|
| 172 |
+
"action_present_to_npc": {"en": "Show {ev} to {char}", "zh": "向{char}出示{ev}"},
|
| 173 |
+
"action_go_to_court": {"en": "Go to court", "zh": "前往法庭"},
|
| 174 |
+
"action_next": {"en": "Next statement", "zh": "下一条证言"},
|
| 175 |
+
"action_prev": {"en": "Previous statement", "zh": "上一条证言"},
|
| 176 |
+
"action_continue": {"en": "Continue", "zh": "继续"},
|
| 177 |
+
|
| 178 |
+
# ── UI labels ──
|
| 179 |
+
"ui_title": {
|
| 180 |
+
"en": "# Turnabout Bench\n*Ace Attorney-style interactive environment for agent long-horizon reasoning evaluation*",
|
| 181 |
+
"zh": "# 逆转法庭\n*逆转裁判风格的 Agent 长程推理评估交互环境*",
|
| 182 |
+
},
|
| 183 |
+
"ui_new_game": {"en": "New Game", "zh": "新游戏"},
|
| 184 |
+
"ui_execute": {"en": "Execute", "zh": "执行"},
|
| 185 |
+
"ui_case": {"en": "Case", "zh": "案件"},
|
| 186 |
+
"ui_difficulty": {"en": "Difficulty", "zh": "难度"},
|
| 187 |
+
"ui_status": {"en": "Status", "zh": "状态"},
|
| 188 |
+
"ui_evidence": {"en": "Evidence", "zh": "证据"},
|
| 189 |
+
"ui_select_action": {"en": "Select an action", "zh": "选择操作"},
|
| 190 |
+
"ui_click_new_game": {
|
| 191 |
+
"en": "Click **New Game** to start.",
|
| 192 |
+
"zh": "点击**新游戏**开始。",
|
| 193 |
+
},
|
| 194 |
+
"ui_no_game": {
|
| 195 |
+
"en": "No game in progress.",
|
| 196 |
+
"zh": "当前没有游戏进行中。",
|
| 197 |
+
},
|
| 198 |
+
"ui_no_evidence": {
|
| 199 |
+
"en": "No evidence collected yet.",
|
| 200 |
+
"zh": "尚未收集到证据。",
|
| 201 |
+
},
|
| 202 |
+
"ui_phase": {"en": "Phase", "zh": "阶段"},
|
| 203 |
+
"ui_penalties": {"en": "Penalties", "zh": "罚分"},
|
| 204 |
+
"ui_contradictions": {"en": "Contradictions", "zh": "矛盾"},
|
| 205 |
+
"ui_steps": {"en": "Steps", "zh": "步数"},
|
| 206 |
+
"ui_result": {"en": "Result", "zh": "结果"},
|
| 207 |
+
"ui_won": {"en": "WON", "zh": "胜诉"},
|
| 208 |
+
"ui_lost": {"en": "LOST", "zh": "败诉"},
|
| 209 |
+
"ui_contradiction_accuracy": {"en": "Contradiction Accuracy", "zh": "矛盾发现准确率"},
|
| 210 |
+
"ui_evidence_coverage": {"en": "Evidence Coverage", "zh": "证据覆盖率"},
|
| 211 |
+
"ui_composite_score": {"en": "Composite Score", "zh": "综合得分"},
|
| 212 |
+
"ui_game_over": {"en": "GAME OVER", "zh": "游戏结束"},
|
| 213 |
+
"ui_reward": {"en": "Reward", "zh": "奖励"},
|
| 214 |
+
"ui_play": {"en": "Play", "zh": "游玩"},
|
| 215 |
+
"ui_generate": {"en": "Generate Case", "zh": "生成案件"},
|
| 216 |
+
"ui_about": {"en": "About", "zh": "关于"},
|
| 217 |
+
"ui_language": {"en": "Language", "zh": "语言"},
|
| 218 |
+
"ui_commands_ref": {"en": "Commands Reference", "zh": "命令参考"},
|
| 219 |
+
"ui_theme": {"en": "Theme (optional)", "zh": "主题(可选)"},
|
| 220 |
+
"ui_generate_btn": {"en": "Generate Case", "zh": "生成案件"},
|
| 221 |
+
"ui_game_label": {"en": "Game", "zh": "游戏"},
|
| 222 |
+
"ui_no_cases": {"en": "No case files found.", "zh": "未找到案件文件。"},
|
| 223 |
+
|
| 224 |
+
# ── Phase display names ──
|
| 225 |
+
"phase_investigation": {"en": "Investigation", "zh": "调查"},
|
| 226 |
+
"phase_court_testimony": {"en": "Testimony", "zh": "证词陈述"},
|
| 227 |
+
"phase_court_cross_examination": {"en": "Cross-Examination", "zh": "质证"},
|
| 228 |
+
"phase_verdict_win": {"en": "Not Guilty", "zh": "无罪"},
|
| 229 |
+
"phase_verdict_lose": {"en": "Guilty", "zh": "有罪"},
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def t(key: str, lang: str = "en") -> str:
|
| 234 |
+
entry = TRANSLATIONS.get(key)
|
| 235 |
+
if entry is None:
|
| 236 |
+
return key
|
| 237 |
+
return entry.get(lang, entry.get("en", key))
|