Spaces:
Running
Persist human Play-tab runs in the standard Playback format
Browse filesAddresses PR #13's concern — saving human-play data for research — the
apples-to-apples way: a human run now produces the IDENTICAL artifact a
model run does, instead of a separate parallel format.
InteractiveSession (the Play tab's turn-stepped session) now emits a
standard Playback dir under the same PLAYBACK_ROOT model runs use:
turns.jsonl per-turn commands / signals / goal
minimap_turnNN.png per-turn frame (same prompt_v2 renderer as models)
messages.json per-turn transcript ({role,content,tool_calls})
manifest.json same shape run_level writes, tagged model=Human /
agent_type=Human
So the Battle Viewer (incl. its compare mode) and the leaderboard
treat human and LLM runs uniformly — human-vs-LLM comparison for free,
no separate schema to reconcile.
- record=True by default; record=False / playback_root= for tests.
- Abandoned sessions still finalize (viewable); never-played sessions
drop their empty dir.
- status() surfaces save_path; the Play tab shows it on game-over.
NOT taken from PR #13: the separate data/human_runs/ JSON format, the
per-session auto-append to results.csv, the data/results.csv reset, the
schema.py stub fallback, and the openra_rl_training shim stubs —
superseded by the proper vendoring already on main.
tests/test_human_labeling.py: +1 test asserting the standard Playback
layout + Human-tagged manifest; 25 green.
- app.py +5 -0
- openra_bench/human_labeling.py +213 -3
- tests/test_human_labeling.py +52 -2
|
@@ -1167,6 +1167,11 @@ def _play_status_md(sess) -> str:
|
|
| 1167 |
line = f"**Turn {st['turn']}/{st['max_turns']}** · tick {st['tick']}"
|
| 1168 |
if st["done"]:
|
| 1169 |
line += f" · **{st['outcome'].upper()}** — game over"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1170 |
return line
|
| 1171 |
|
| 1172 |
|
|
|
|
| 1167 |
line = f"**Turn {st['turn']}/{st['max_turns']}** · tick {st['tick']}"
|
| 1168 |
if st["done"]:
|
| 1169 |
line += f" · **{st['outcome'].upper()}** — game over"
|
| 1170 |
+
if st.get("save_path"):
|
| 1171 |
+
line += (
|
| 1172 |
+
f"\n\n_Run saved (standard playback format): "
|
| 1173 |
+
f"`{st['save_path']}`_"
|
| 1174 |
+
)
|
| 1175 |
return line
|
| 1176 |
|
| 1177 |
|
|
@@ -468,7 +468,14 @@ class InteractiveSession:
|
|
| 468 |
s.close()
|
| 469 |
"""
|
| 470 |
|
| 471 |
-
def __init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
from openra_rl_training.training.rust_env_pool import RustEnvPool
|
| 473 |
|
| 474 |
from .eval_core import _scenario_to_tmp_yaml
|
|
@@ -492,6 +499,21 @@ class InteractiveSession:
|
|
| 492 |
self.outcome = "draw"
|
| 493 |
self.done = False
|
| 494 |
self._closed = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
|
| 496 |
# A live engine session is a resource handle, not a value. Gradio's
|
| 497 |
# `gr.State` deep-copies whatever it holds — and the RustEnvPool
|
|
@@ -506,7 +528,13 @@ class InteractiveSession:
|
|
| 506 |
|
| 507 |
@classmethod
|
| 508 |
def from_pack(
|
| 509 |
-
cls,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
) -> "InteractiveSession":
|
| 511 |
"""Compile a pack by id and open a session on it."""
|
| 512 |
from .scenarios import load_pack
|
|
@@ -515,7 +543,10 @@ class InteractiveSession:
|
|
| 515 |
compiled = compile_level(
|
| 516 |
load_pack(PACKS_DIR / f"{pack_id}.yaml"), level
|
| 517 |
)
|
| 518 |
-
return cls(
|
|
|
|
|
|
|
|
|
|
| 519 |
|
| 520 |
@property
|
| 521 |
def Command(self) -> Any:
|
|
@@ -545,8 +576,155 @@ class InteractiveSession:
|
|
| 545 |
"outcome": self.outcome,
|
| 546 |
"done": self.done,
|
| 547 |
"tick": self._adapter.signals.game_tick,
|
|
|
|
| 548 |
}
|
| 549 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 550 |
def submit_turn(self, actions: "list[HumanAction]") -> dict:
|
| 551 |
"""Advance one decision turn with the human's gestures. Mirrors
|
| 552 |
`run_level`'s per-turn body. Returns the updated `status()`."""
|
|
@@ -592,6 +770,10 @@ class InteractiveSession:
|
|
| 592 |
or self.turn >= self.max_turns
|
| 593 |
):
|
| 594 |
self.done = True
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
return self.status()
|
| 596 |
|
| 597 |
def close(self) -> None:
|
|
@@ -599,6 +781,34 @@ class InteractiveSession:
|
|
| 599 |
if self._closed:
|
| 600 |
return
|
| 601 |
self._closed = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 602 |
try:
|
| 603 |
self._pool.release(self._env)
|
| 604 |
self._pool.shutdown()
|
|
|
|
| 468 |
s.close()
|
| 469 |
"""
|
| 470 |
|
| 471 |
+
def __init__(
|
| 472 |
+
self,
|
| 473 |
+
compiled: Any,
|
| 474 |
+
seed: int = 1,
|
| 475 |
+
record: bool = True,
|
| 476 |
+
playback_root: Any = None,
|
| 477 |
+
player: str = "Human",
|
| 478 |
+
):
|
| 479 |
from openra_rl_training.training.rust_env_pool import RustEnvPool
|
| 480 |
|
| 481 |
from .eval_core import _scenario_to_tmp_yaml
|
|
|
|
| 499 |
self.outcome = "draw"
|
| 500 |
self.done = False
|
| 501 |
self._closed = False
|
| 502 |
+
self.save_path: str | None = None
|
| 503 |
+
# Standard-playback recording: a human run produces the IDENTICAL
|
| 504 |
+
# artifact a model run does — a Playback dir (turns.jsonl,
|
| 505 |
+
# per-turn minimap PNGs, messages.json, manifest.json) under the
|
| 506 |
+
# same PLAYBACK_ROOT — so the Battle Viewer and leaderboard treat
|
| 507 |
+
# human and LLM runs apples-to-apples (no separate human format).
|
| 508 |
+
self._player = player
|
| 509 |
+
self._playback = None
|
| 510 |
+
self._finalized = False
|
| 511 |
+
self._history: list[dict] = []
|
| 512 |
+
self._issued = 0
|
| 513 |
+
self._pb_terrain = None
|
| 514 |
+
self._pb_explored: set = set()
|
| 515 |
+
if record:
|
| 516 |
+
self._setup_playback(playback_root)
|
| 517 |
|
| 518 |
# A live engine session is a resource handle, not a value. Gradio's
|
| 519 |
# `gr.State` deep-copies whatever it holds — and the RustEnvPool
|
|
|
|
| 528 |
|
| 529 |
@classmethod
|
| 530 |
def from_pack(
|
| 531 |
+
cls,
|
| 532 |
+
pack_id: str,
|
| 533 |
+
level: str = "easy",
|
| 534 |
+
seed: int = 1,
|
| 535 |
+
record: bool = True,
|
| 536 |
+
playback_root: Any = None,
|
| 537 |
+
player: str = "Human",
|
| 538 |
) -> "InteractiveSession":
|
| 539 |
"""Compile a pack by id and open a session on it."""
|
| 540 |
from .scenarios import load_pack
|
|
|
|
| 543 |
compiled = compile_level(
|
| 544 |
load_pack(PACKS_DIR / f"{pack_id}.yaml"), level
|
| 545 |
)
|
| 546 |
+
return cls(
|
| 547 |
+
compiled, seed=seed, record=record,
|
| 548 |
+
playback_root=playback_root, player=player,
|
| 549 |
+
)
|
| 550 |
|
| 551 |
@property
|
| 552 |
def Command(self) -> Any:
|
|
|
|
| 576 |
"outcome": self.outcome,
|
| 577 |
"done": self.done,
|
| 578 |
"tick": self._adapter.signals.game_tick,
|
| 579 |
+
"save_path": self.save_path,
|
| 580 |
}
|
| 581 |
|
| 582 |
+
# ── Standard-playback recording ──────────────────────────────────
|
| 583 |
+
def _setup_playback(self, playback_root: Any) -> None:
|
| 584 |
+
"""Open a Playback dir — the same one model runs use."""
|
| 585 |
+
try:
|
| 586 |
+
import os
|
| 587 |
+
from datetime import datetime, timezone
|
| 588 |
+
from pathlib import Path
|
| 589 |
+
|
| 590 |
+
from .playback import Playback
|
| 591 |
+
|
| 592 |
+
root = Path(
|
| 593 |
+
playback_root
|
| 594 |
+
or os.environ.get(
|
| 595 |
+
"OPENRA_BENCH_PLAYBACK_ROOT",
|
| 596 |
+
Path(__file__).resolve().parents[1] / "playback",
|
| 597 |
+
)
|
| 598 |
+
)
|
| 599 |
+
run_id = "human-" + datetime.now(timezone.utc).strftime(
|
| 600 |
+
"%Y%m%dT%H%M%SZ"
|
| 601 |
+
)
|
| 602 |
+
safe = "".join(
|
| 603 |
+
c if (c.isalnum() or c in "-_") else "-"
|
| 604 |
+
for c in self._player
|
| 605 |
+
)
|
| 606 |
+
pb = Playback(
|
| 607 |
+
root / f"{run_id}__{safe}",
|
| 608 |
+
cell=f"{self.compiled.pack_id}:{self.compiled.level}",
|
| 609 |
+
seed=self.seed,
|
| 610 |
+
)
|
| 611 |
+
pb.run_id, pb.model = run_id, self._player
|
| 612 |
+
self._playback = pb
|
| 613 |
+
self._history = [{
|
| 614 |
+
"role": "system",
|
| 615 |
+
"content": (
|
| 616 |
+
f"Human-labeling session — {self.compiled.pack_id}:"
|
| 617 |
+
f"{self.compiled.level} seed {self.seed}. Objective: "
|
| 618 |
+
f"{self.objective or '(see briefing)'}"
|
| 619 |
+
),
|
| 620 |
+
}]
|
| 621 |
+
except Exception: # noqa: BLE001 — recording never breaks play
|
| 622 |
+
self._playback = None
|
| 623 |
+
|
| 624 |
+
def _pb_frame(self, rs: dict) -> "str | None":
|
| 625 |
+
"""Per-turn minimap PNG (b64) via the SAME renderer model
|
| 626 |
+
playback uses, so saved human frames match model frames."""
|
| 627 |
+
try:
|
| 628 |
+
from .minimap import terrain_png_for
|
| 629 |
+
|
| 630 |
+
if self._pb_terrain is None:
|
| 631 |
+
self._pb_terrain = terrain_png_for(
|
| 632 |
+
self.compiled.scenario.base_map
|
| 633 |
+
)
|
| 634 |
+
from .prompt_v2 import minimap_b64 as _v2_mm
|
| 635 |
+
|
| 636 |
+
png = _v2_mm(
|
| 637 |
+
rs, self._pb_terrain, self._pb_explored,
|
| 638 |
+
constant_colors=self.compiled.level in ("easy", "medium"),
|
| 639 |
+
)
|
| 640 |
+
if png is None:
|
| 641 |
+
from .agent import _render_minimap_b64
|
| 642 |
+
|
| 643 |
+
png = _render_minimap_b64(rs, self._pb_terrain)
|
| 644 |
+
return png
|
| 645 |
+
except Exception: # noqa: BLE001
|
| 646 |
+
return None
|
| 647 |
+
|
| 648 |
+
def _record_turn_playback(self, actions, calls, cmds, ctx) -> None:
|
| 649 |
+
"""Append one turn to the Playback + the message transcript."""
|
| 650 |
+
if self._playback is None:
|
| 651 |
+
return
|
| 652 |
+
from .goal_tracker import turn_goal
|
| 653 |
+
|
| 654 |
+
rs = self._adapter.render_state()
|
| 655 |
+
self._issued += len(cmds)
|
| 656 |
+
self._playback.record_turn(
|
| 657 |
+
self.turn, rs, cmds, self._adapter.signals,
|
| 658 |
+
self._pb_frame(rs),
|
| 659 |
+
goal=turn_goal(self.compiled.win_condition, ctx),
|
| 660 |
+
)
|
| 661 |
+
# Transcript turn — same {role, content, tool_calls} shape a
|
| 662 |
+
# ModelAgent writes, so messages.json is uniform across human
|
| 663 |
+
# and LLM runs.
|
| 664 |
+
self._history.append(
|
| 665 |
+
{"role": "user", "content": HumanController._briefing(rs)}
|
| 666 |
+
)
|
| 667 |
+
self._history.append({
|
| 668 |
+
"role": "assistant",
|
| 669 |
+
"content": "; ".join(a.describe() for a in actions) or "observe",
|
| 670 |
+
"reasoning": "; ".join(a.note for a in actions if a.note),
|
| 671 |
+
"tool_calls": [
|
| 672 |
+
{
|
| 673 |
+
"id": f"h{i}", "type": "function",
|
| 674 |
+
"function": {
|
| 675 |
+
"name": c["name"], "arguments": c["arguments"],
|
| 676 |
+
},
|
| 677 |
+
}
|
| 678 |
+
for i, c in enumerate(calls)
|
| 679 |
+
],
|
| 680 |
+
})
|
| 681 |
+
for i in range(len(calls)):
|
| 682 |
+
self._history.append(
|
| 683 |
+
{"role": "tool", "tool_call_id": f"h{i}", "content": "ok"}
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
def _finalize_playback(self, ctx) -> None:
|
| 687 |
+
"""Write messages.json + manifest.json — the same manifest shape
|
| 688 |
+
`run_level` writes for a model run, tagged as a human run."""
|
| 689 |
+
if self._playback is None or self._finalized:
|
| 690 |
+
return
|
| 691 |
+
self._finalized = True
|
| 692 |
+
from .goal_tracker import turn_goal
|
| 693 |
+
|
| 694 |
+
final_goal = turn_goal(self.compiled.win_condition, ctx)
|
| 695 |
+
sig = self._adapter.signals
|
| 696 |
+
try:
|
| 697 |
+
self._playback.write_messages(self._history)
|
| 698 |
+
self._playback.finalize({
|
| 699 |
+
"scenario": f"{self.compiled.pack_id}:{self.compiled.level}",
|
| 700 |
+
"pack_id": self.compiled.pack_id,
|
| 701 |
+
"level": self.compiled.level,
|
| 702 |
+
"capability": self.compiled.meta.capability,
|
| 703 |
+
"run_id": self._playback.run_id,
|
| 704 |
+
"model": self._playback.model,
|
| 705 |
+
"agent_type": "Human",
|
| 706 |
+
"seed": self.seed,
|
| 707 |
+
"outcome": self.outcome,
|
| 708 |
+
"turns": self.turn,
|
| 709 |
+
"max_turns": self.max_turns,
|
| 710 |
+
"actions_issued": self._issued,
|
| 711 |
+
"actions_warned": 0,
|
| 712 |
+
"agent_stats": {"turns": self.turn},
|
| 713 |
+
"objective_progress": final_goal.get(
|
| 714 |
+
"objective_progress", 0.0
|
| 715 |
+
),
|
| 716 |
+
"reward_vector": final_goal.get("reward_vector", {}),
|
| 717 |
+
"signals": {
|
| 718 |
+
"economy_value": sig.cash + sig.resources,
|
| 719 |
+
"explored_percent": round(sig.explored_percent, 2),
|
| 720 |
+
"units_killed": sig.units_killed,
|
| 721 |
+
"units_lost": sig.units_lost,
|
| 722 |
+
},
|
| 723 |
+
})
|
| 724 |
+
self.save_path = str(self._playback.dir)
|
| 725 |
+
except Exception: # noqa: BLE001
|
| 726 |
+
pass
|
| 727 |
+
|
| 728 |
def submit_turn(self, actions: "list[HumanAction]") -> dict:
|
| 729 |
"""Advance one decision turn with the human's gestures. Mirrors
|
| 730 |
`run_level`'s per-turn body. Returns the updated `status()`."""
|
|
|
|
| 770 |
or self.turn >= self.max_turns
|
| 771 |
):
|
| 772 |
self.done = True
|
| 773 |
+
# Standard-playback recording — log this turn, finalize on done.
|
| 774 |
+
self._record_turn_playback(actions or [], calls, cmds, ctx)
|
| 775 |
+
if self.done:
|
| 776 |
+
self._finalize_playback(ctx)
|
| 777 |
return self.status()
|
| 778 |
|
| 779 |
def close(self) -> None:
|
|
|
|
| 781 |
if self._closed:
|
| 782 |
return
|
| 783 |
self._closed = True
|
| 784 |
+
# An abandoned run (closed before game-over) is still finalized
|
| 785 |
+
# so its Playback dir carries a manifest and stays viewable.
|
| 786 |
+
if (
|
| 787 |
+
self._playback is not None
|
| 788 |
+
and not self._finalized
|
| 789 |
+
and self.turn > 0
|
| 790 |
+
):
|
| 791 |
+
try:
|
| 792 |
+
from .scenarios.win_conditions import WinContext
|
| 793 |
+
|
| 794 |
+
self._finalize_playback(
|
| 795 |
+
WinContext(
|
| 796 |
+
signals=self._adapter.signals,
|
| 797 |
+
render_state=self._adapter.render_state(),
|
| 798 |
+
)
|
| 799 |
+
)
|
| 800 |
+
except Exception: # noqa: BLE001
|
| 801 |
+
pass
|
| 802 |
+
elif self._playback is not None and self.turn == 0:
|
| 803 |
+
# Never-played recorded session — drop the empty dir rather
|
| 804 |
+
# than littering the playback root.
|
| 805 |
+
import shutil
|
| 806 |
+
|
| 807 |
+
try:
|
| 808 |
+
self._playback._turns_fh.close()
|
| 809 |
+
except Exception: # noqa: BLE001
|
| 810 |
+
pass
|
| 811 |
+
shutil.rmtree(self._playback.dir, ignore_errors=True)
|
| 812 |
try:
|
| 813 |
self._pool.release(self._env)
|
| 814 |
self._pool.shutdown()
|
|
@@ -339,7 +339,7 @@ def test_interactive_session_steps_turn_by_turn():
|
|
| 339 |
compiled = _smallest_easy_pack()
|
| 340 |
assert compiled is not None
|
| 341 |
|
| 342 |
-
sess = InteractiveSession(compiled, seed=1)
|
| 343 |
try:
|
| 344 |
# The observation is a real render_state — what the LLM sees.
|
| 345 |
rs = sess.render_state()
|
|
@@ -372,7 +372,9 @@ def test_interactive_session_from_pack():
|
|
| 372 |
from openra_bench.human_labeling import InteractiveSession
|
| 373 |
|
| 374 |
compiled = _smallest_easy_pack()
|
| 375 |
-
sess = InteractiveSession.from_pack(
|
|
|
|
|
|
|
| 376 |
try:
|
| 377 |
assert sess.seed == 2
|
| 378 |
assert sess.Command is not None
|
|
@@ -387,3 +389,51 @@ def test_interactive_session_from_pack():
|
|
| 387 |
assert sess.turn == 1
|
| 388 |
finally:
|
| 389 |
sess.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
compiled = _smallest_easy_pack()
|
| 340 |
assert compiled is not None
|
| 341 |
|
| 342 |
+
sess = InteractiveSession(compiled, seed=1, record=False)
|
| 343 |
try:
|
| 344 |
# The observation is a real render_state — what the LLM sees.
|
| 345 |
rs = sess.render_state()
|
|
|
|
| 372 |
from openra_bench.human_labeling import InteractiveSession
|
| 373 |
|
| 374 |
compiled = _smallest_easy_pack()
|
| 375 |
+
sess = InteractiveSession.from_pack(
|
| 376 |
+
compiled.pack_id, "easy", seed=2, record=False
|
| 377 |
+
)
|
| 378 |
try:
|
| 379 |
assert sess.seed == 2
|
| 380 |
assert sess.Command is not None
|
|
|
|
| 389 |
assert sess.turn == 1
|
| 390 |
finally:
|
| 391 |
sess.close()
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def test_interactive_session_emits_standard_playback(tmp_path):
|
| 395 |
+
"""A recorded human session produces the SAME Playback artifact a
|
| 396 |
+
model run does — turns.jsonl + per-turn minimap PNGs + messages.json
|
| 397 |
+
+ a manifest — so the Battle Viewer / leaderboard treat human and
|
| 398 |
+
LLM runs apples-to-apples."""
|
| 399 |
+
import json
|
| 400 |
+
from pathlib import Path
|
| 401 |
+
|
| 402 |
+
from openra_bench.human_labeling import InteractiveSession
|
| 403 |
+
|
| 404 |
+
compiled = _smallest_easy_pack()
|
| 405 |
+
assert compiled is not None
|
| 406 |
+
|
| 407 |
+
sess = InteractiveSession(
|
| 408 |
+
compiled, seed=1, record=True, playback_root=tmp_path,
|
| 409 |
+
player="Human",
|
| 410 |
+
)
|
| 411 |
+
try:
|
| 412 |
+
guard = compiled.max_turns + 5
|
| 413 |
+
steps = 0
|
| 414 |
+
while not sess.done and steps < guard:
|
| 415 |
+
sess.submit_turn([HumanAction(mode="observe")])
|
| 416 |
+
steps += 1
|
| 417 |
+
assert sess.done
|
| 418 |
+
save = sess.status()["save_path"]
|
| 419 |
+
assert save, "a finished recorded session must report save_path"
|
| 420 |
+
run_dir = Path(save)
|
| 421 |
+
# Standard Playback layout — identical to a model run's.
|
| 422 |
+
assert (run_dir / "turns.jsonl").is_file()
|
| 423 |
+
assert (run_dir / "messages.json").is_file()
|
| 424 |
+
manifest = json.loads((run_dir / "manifest.json").read_text())
|
| 425 |
+
assert manifest["model"] == "Human"
|
| 426 |
+
assert manifest["agent_type"] == "Human"
|
| 427 |
+
assert manifest["pack_id"] == compiled.pack_id
|
| 428 |
+
assert manifest["outcome"] in ("win", "loss", "draw")
|
| 429 |
+
assert manifest["turns"] == sess.turn
|
| 430 |
+
# Per-turn minimap PNG frames were written.
|
| 431 |
+
assert list(run_dir.glob("minimap_turn*.png"))
|
| 432 |
+
# turns.jsonl has one record per turn.
|
| 433 |
+
lines = [
|
| 434 |
+
ln for ln in (run_dir / "turns.jsonl").read_text().splitlines()
|
| 435 |
+
if ln.strip()
|
| 436 |
+
]
|
| 437 |
+
assert len(lines) == sess.turn
|
| 438 |
+
finally:
|
| 439 |
+
sess.close()
|