Spaces:
Running
Running
| """Non-gamer playlist โ the 20-pack curated baseline. | |
| The 24-pack `human_study` (`openra_bench/human_study.py`) is built for | |
| recruited testers who can be expected to read jargon and grind through | |
| 72 games. A non-gamer cold-clicking through the Play tab cannot do that | |
| โ they need a curated, accessible 20-pack run that finishes in | |
| 60-90 minutes WITHOUT requiring RTS knowledge. | |
| This module is the support data for the **Playlist** tab in `app.py`: | |
| * `NOVICE_PLAYLIST` โ 20 hand-picked (pack, level) pairs whose | |
| objective is visually obvious on the minimap and whose tool surface | |
| fits the reduced palette (move / attack / build / end-turn). Each | |
| pack chosen because: | |
| - the objective is "drive your green units to the yellow ring" or | |
| "kill the visible red enemies" (no jargon decoding required); | |
| - the tool surface is `move_units` + `attack_unit` (and at most | |
| `build` for one or two build-tier picks); no `repair`, | |
| `power_down`, `set_primary`, `infiltrate`, `fire_superweapon`, | |
| `set_stance`; | |
| - the timer is generous (โฅ30 decision turns at easy/medium); | |
| - the easy-level WIN bar has been verified by an intended scripted | |
| policy in the existing test suite. | |
| * `JARGON_TO_PLAIN` โ the render-time substitution dict that converts | |
| RTS shorthand ("MCV", "harvester", "barracks") into plain English | |
| ("base builder", "miner", "infantry factory") for the simplified | |
| briefing. Applied at the UI layer ONLY โ the underlying YAML, the | |
| engine, the tool API, the playback transcript still use the canonical | |
| short codes, so a Playlist-mode run is still apples-to-apple with a | |
| model run on the same pack. | |
| * `simplify_briefing` / `simplify_objective` โ apply | |
| `JARGON_TO_PLAIN` to a string. Used by the Playlist tab to produce | |
| the 1-2 sentence plain-English objective shown above the minimap. | |
| * `playlist_progress(idx, total)` โ turn (i, n) into a 0..1 fraction | |
| for the progress bar. | |
| Auto-advance state-machine: | |
| * `playlist_should_advance(status, last_done_at, now, wait_seconds)` | |
| โ True if a game is over AND the wait countdown has elapsed. | |
| Everything here is pure-Python, no engine handles required, so the | |
| playlist plumbing tests run even when the Rust wheel is absent. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from typing import Iterable | |
| # โโ The curated 20-pack list โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # Hand-picked from the 210 active packs for accessibility: | |
| # - visually obvious objective (yellow ring or visible red enemies) | |
| # - reduced tool surface (move + attack only, in most cases) | |
| # - generous timer (โฅ30 decision turns) | |
| # - easy tier โ never medium/hard for a non-gamer | |
| # | |
| # Stratified across 5 capability buckets so the player sees variety: | |
| # * 8 combat (move tanks, kill visible enemies) | |
| # * 4 coordination (move multiple groups) | |
| # * 4 defense (stay near base, kill incoming) | |
| # * 2 reach-region (drive to yellow ring) | |
| # * 2 multi-objective (split groups between rings) | |
| # | |
| # Why each pack made the cut (one-liner per pack โ also doc'd in the | |
| # tab's preset description): | |
| NOVICE_PLAYLIST: list[tuple[str, str]] = [ | |
| # โโ Combat โ kill the visible red enemies โโโโโโโโโโโโโโโโโโโโ | |
| # 4 tanks vs visible static squad; just drive east and shoot. | |
| ("combat-focus-fire-priority", "easy"), | |
| # Two groups converge, fight at one point โ visible attack target. | |
| ("combat-pincer-coordination", "easy"), | |
| # 4 tanks, single visible enemy line โ straight-line attack. | |
| ("combat-flanking-attack", "easy"), | |
| # 5 tanks east through fire corridor โ push through. | |
| ("combat-formation-tank-wedge", "easy"), | |
| # 4 tanks vs 2 visible clusters โ pick one, then the other. | |
| ("combat-divide-and-conquer", "easy"), | |
| # Hold a chokepoint vs incoming โ defensive but visible. | |
| ("combat-hold-chokepoint", "easy"), | |
| # Skirmish + back off โ one engagement, visible enemy. | |
| ("combat-skirmish-then-disengage", "easy"), | |
| # 4 tanks retreat and re-engage โ visible enemy, simple. | |
| ("combat-retreat-after-engagement", "easy"), | |
| # โโ Coordination โ move multiple groups โโโโโโโโโโโโโโโโโโโโโโ | |
| # 6 tanks west edge โ punch east, converge on objective. | |
| ("coord-mutual-support", "easy"), | |
| # 3 columns from 3 bearings โ pick one ring at a time. | |
| ("coord-converge-on-target", "easy"), | |
| # 2 squads cross fire zone โ staggered move. | |
| ("coord-cover-and-move", "easy"), | |
| # Multi-region: move 2 units into each of 2 yellow rings. | |
| ("action-multiunit-coordination", "easy"), | |
| # โโ Defense โ stay near base, kill incoming โโโโโโโโโโโโโโโโโโ | |
| # Defenders hold base vs probing raiders โ wait + shoot. | |
| ("proc-only-defend-no-attack", "easy"), | |
| # Heavy assault on base โ fall back to evac point. | |
| ("def-evacuation", "easy"), | |
| # Waves from multiple directions โ distribute defenders. | |
| ("def-multi-direction", "easy"), | |
| # Mobile reserve plugs the breach โ react to single lane. | |
| ("def-reinforce-the-breach", "easy"), | |
| # โโ Reach-region โ drive to the yellow ring โโโโโโโโโโโโโโโโโโ | |
| # Bait-counter โ guard line; pull tigers off the mountain. | |
| ("artofwar-lure-the-tiger", "easy"), | |
| # 4 tanks face cluster behind fog โ drive east, kill. | |
| ("combat-attack-from-behind-fog", "easy"), | |
| # โโ Multi-objective easier โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # Visible counter-attack target โ bait then kill. | |
| ("combat-bait-counter-attack", "easy"), | |
| # 3 fast jeeps kite a heavy tank โ visible single target. | |
| ("combat-kite-jeep-vs-tank", "easy"), | |
| ] | |
| # โโ Render-time jargon substitutions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # RTS shorthand โ plain English. Applied at the UI rendering layer | |
| # ONLY (briefings, unit-table type column). The engine, tool API, YAML | |
| # pack files, playback transcripts, and command translator continue to | |
| # use the canonical short codes โ so the underlying run is still | |
| # apples-to-apple with a model run on the same scenario. | |
| # | |
| # Keys are matched as whole words/phrases (case-insensitive). Longer | |
| # multi-word keys are applied first so "construction yard" wins over | |
| # "yard". Short-code keys (mcv, harv) are matched as whole words so | |
| # "harvester" doesn't double-substitute. | |
| JARGON_TO_PLAIN: dict[str, str] = { | |
| # Buildings โ full names FIRST so "construction yard" beats "yard". | |
| "construction yard": "main base", | |
| "ore refinery": "miner depot", | |
| "war factory": "tank factory", | |
| "tank factory": "tank factory", # passthrough; keeps display stable | |
| "service depot": "repair shop", | |
| "advanced power plant": "big power plant", | |
| "barracks": "infantry factory", | |
| "soviet barracks": "infantry factory", | |
| "allied barracks": "infantry factory", | |
| "ore silo": "ore storage", | |
| "radar dome": "radar", | |
| # Units โ full names. | |
| "harvester": "miner", | |
| "rocket soldier": "rocket trooper", | |
| "rifle infantry": "rifleman", | |
| "rifle soldier": "rifleman", | |
| "grenadier": "grenade trooper", | |
| "engineer": "engineer", # passthrough โ already plain | |
| "medium tank": "medium tank", # passthrough | |
| "heavy tank": "heavy tank", | |
| "mammoth tank": "mammoth tank", | |
| "light tank": "light tank", | |
| "anti-tank": "anti-tank", | |
| # Defenses. | |
| "pillbox": "guard tower", | |
| "tesla coil": "lightning tower", | |
| "anti-aircraft": "anti-air", | |
| "turret": "guard turret", | |
| # Short codes โ whole-word match keeps these from clobbering | |
| # multi-letter words. | |
| "mcv": "base builder", | |
| "harv": "miner", | |
| "fact": "main base", | |
| "proc": "miner depot", | |
| "powr": "power plant", | |
| "apwr": "big power plant", | |
| "barr": "infantry factory", | |
| "tent": "infantry factory", | |
| "weap": "tank factory", | |
| "hpad": "helipad", | |
| "afld": "airfield", | |
| "fix": "repair shop", | |
| "dome": "radar", | |
| "silo": "ore storage", | |
| "pbox": "guard tower", | |
| "hbox": "guard tower", | |
| "tsla": "lightning tower", | |
| "gun": "guard turret", | |
| "sam": "anti-air", | |
| # Unit short codes. | |
| "1tnk": "light tank", | |
| "2tnk": "medium tank", | |
| "3tnk": "heavy tank", | |
| "4tnk": "mammoth tank", | |
| "e1": "rifleman", | |
| "e2": "grenade trooper", | |
| "e3": "rocket trooper", | |
| "e6": "engineer", | |
| "arty": "artillery", | |
| "apc": "transport", | |
| "jeep": "jeep", | |
| "lst": "landing craft", | |
| "dog": "attack dog", | |
| } | |
| # Pre-compile a single regex: longest keys first, escaped, word-bounded. | |
| def _compile_jargon_pattern() -> "re.Pattern[str]": | |
| keys = sorted(JARGON_TO_PLAIN.keys(), key=len, reverse=True) | |
| # Use lookarounds so short codes (e1, mcv) only match as whole | |
| # tokens โ but multi-word keys like "ore refinery" still match the | |
| # full phrase even with internal spaces. | |
| parts = [] | |
| for k in keys: | |
| esc = re.escape(k) | |
| parts.append(rf"(?<![A-Za-z0-9_-]){esc}(?![A-Za-z0-9_-])") | |
| return re.compile("|".join(parts), flags=re.IGNORECASE) | |
| _JARGON_RE = _compile_jargon_pattern() | |
| def simplify_text(text: str) -> str: | |
| """Apply `JARGON_TO_PLAIN` substitutions to a single string. The | |
| match is case-insensitive, but each replacement is lowercased so | |
| the briefing reads as plain prose โ `MCV` and `mcv` both render | |
| as `base builder`.""" | |
| if not text: | |
| return text | |
| def _sub(m: "re.Match[str]") -> str: | |
| return JARGON_TO_PLAIN[m.group(0).lower()] | |
| return _JARGON_RE.sub(_sub, text) | |
| def simplify_objective(text: str, max_chars: int = 320) -> str: | |
| """One-or-two-sentence plain-English objective for the Playlist | |
| panel. Strips the structured `WIN WHEN: โฆ` / `YOU LOSE IF: โฆ` | |
| suffix `objective_brief` appends โ those are jargon-heavy and | |
| we surface them behind the **Details** expand instead.""" | |
| if not text: | |
| return text | |
| plain = simplify_text(text) | |
| # Drop everything from the structured WIN/LOSE machine block onward. | |
| for marker in ("WIN WHEN:", "YOU LOSE IF:", "You have at most"): | |
| idx = plain.find(marker) | |
| if idx >= 0: | |
| plain = plain[:idx] | |
| plain = plain.strip() | |
| # Keep the first 1-2 sentences; cap at max_chars. | |
| sentences = re.split(r"(?<=[.!?])\s+", plain) | |
| out = " ".join(sentences[:2]).strip() | |
| if len(out) > max_chars: | |
| out = out[: max_chars - 1].rstrip() + "โฆ" | |
| return out | |
| # โโ Reduced command palette โ what the Playlist tab exposes โโโโโโโโ | |
| # A non-gamer never needs `repair`, `power_down`, `set_primary`, | |
| # `infiltrate`, `fire_superweapon`, `set_stance`. The Playlist tab | |
| # only exposes: | |
| # * `move` โ click-and-drag on minimap (already wired) | |
| # * `attack` โ click an enemy with units selected | |
| # * `build` โ the textbox already there (only when needed) | |
| # * `end turn` โ the button at the bottom | |
| PLAYLIST_TOOLS: tuple[str, ...] = ( | |
| "move_units", "attack_unit", "attack_move", "stop", "build", | |
| ) | |
| def needs_build_tool(pack_tools: Iterable[str] | None) -> bool: | |
| """True if the pack's authored `tools:` list contains `build` | |
| (or a build-tier verb like `place_building`). For Playlist mode | |
| the Build textbox is hidden unless the pack actually requires it | |
| โ keeps the UI sparse for the (majority) move-and-shoot scenarios. | |
| """ | |
| if not pack_tools: | |
| return False | |
| s = {str(t).lower() for t in pack_tools} | |
| return bool(s & {"build", "place_building", "cancel_production"}) | |
| # โโ Auto-advance state machine โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # A 5-second countdown shown after game-over, then auto-load next pack. | |
| AUTO_ADVANCE_WAIT_SECONDS = 5.0 | |
| def playlist_should_advance( | |
| done: bool, | |
| done_at: float | None, | |
| now: float, | |
| wait_seconds: float = AUTO_ADVANCE_WAIT_SECONDS, | |
| ) -> bool: | |
| """True if the current scenario is over AND the post-game wait has | |
| elapsed โ the auto-advance timer should fire NEXT. | |
| `done_at` is the wall-clock timestamp at which the session first | |
| transitioned to `done`. The first poll after game-over passes | |
| `done_at = now`; subsequent polls compare the elapsed delta. A | |
| `done=False` always returns False (we never advance mid-game).""" | |
| if not done or done_at is None: | |
| return False | |
| return (now - done_at) >= max(0.0, wait_seconds) | |
| def playlist_progress(idx: int, total: int) -> float: | |
| """0..1 fraction for the progress bar โ `idx` games completed out | |
| of `total`. Clamped โ over-/under-flow is a UI input bug, not a | |
| crash.""" | |
| if total <= 0: | |
| return 0.0 | |
| return max(0.0, min(1.0, float(idx) / float(total))) | |
| def playlist_progress_bar(idx: int, total: int, width: int = 20) -> str: | |
| """Plain-text progress bar โ 'โฎโฎโฎโฎโฎโฎโฎโฎโโโโโโโโโโโโโ 35%'. Used | |
| when a Markdown pre-formatted block is preferable to a real | |
| `gr.Progress` (e.g. inside a Markdown block on tab-load before | |
| any state has been set).""" | |
| frac = playlist_progress(idx, total) | |
| filled = int(round(frac * width)) | |
| bar = "โฎ" * filled + "โ" * (width - filled) | |
| return f"{bar} {int(round(frac * 100))}%" | |
| def session_summary_row( | |
| idx: int, | |
| pack: str, | |
| level: str, | |
| outcome: str, | |
| turns: int, | |
| max_turns: int, | |
| ) -> dict: | |
| """One row of the session-end summary table โ same shape across | |
| Playlist tab and the underlying playback manifest, so the summary | |
| is sourced from the same place every time.""" | |
| return { | |
| "Game": idx + 1, | |
| "Scenario": pack, | |
| "Level": level, | |
| "Outcome": (outcome or "draw").upper(), | |
| "Turns": int(turns or 0), | |
| "Max Turns": int(max_turns or 0), | |
| } | |