王致渊 commited on
Commit
57e0211
·
0 Parent(s):

Initial commit

Browse files
.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))