From 2f0e54a6e8d820d9464be39cec3f0e77ddd99a98 Mon Sep 17 00:00:00 2001 From: Deepro713 Date: Sun, 7 Jun 2026 17:24:58 +0530 Subject: [PATCH] =?UTF-8?q?feat(adapter):=20headless=20game=20adapter=20+?= =?UTF-8?q?=20route=20CLI=20through=20it=20=E2=80=94=20M011?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New neo_handcricket/adapter.py: GameAdapter(AdapterConfig) drives a user-batting innings headlessly (state() + submit_pick(0..6) → outcome/events/state; bowler rotation + opponent-model bookkeeping + event detection; deterministic, no I/O). bot_bowl_pick() is the single source of truth for the bot's bowling pick; the CLI ball-loop now routes through it (no behaviour change). 8 unit tests + 2 playtest invariants (gate now 62). Gate green (ruff + mypy 56 files + 149 tests + playtest 62/62). ADR-0027. Closes #77 Closes #76 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/00-overview/decision-log.md | 20 +++ docs/03-issues/m011-adapter-refactor-cli.md | 4 +- docs/03-issues/m011-headless-adapter.md | 4 +- neo_handcricket/adapter.py | 177 ++++++++++++++++++++ neo_handcricket/main.py | 14 +- tests/test_adapter.py | 77 +++++++++ tools/playtest/__main__.py | 18 ++ 7 files changed, 303 insertions(+), 11 deletions(-) create mode 100644 neo_handcricket/adapter.py create mode 100644 tests/test_adapter.py diff --git a/docs/00-overview/decision-log.md b/docs/00-overview/decision-log.md index 3c5a4e8..8fa65d9 100644 --- a/docs/00-overview/decision-log.md +++ b/docs/00-overview/decision-log.md @@ -7,6 +7,26 @@ type: reference Architecture Decision Records, newest first. One per cluster/significant decision. +## ADR-0027 — M011 cluster `adapter`: a UI-agnostic headless game adapter +**Date:** 2026-06-07 · **Status:** accepted · **Cluster:** `m011/adapter` · **Issues:** #77, #76 + +**Context.** A future web/GUI port (offline-safe) needs the engine drivable without any I/O. The adapter +is that seam. + +**Decision.** New module `neo_handcricket/adapter.py`: +- `GameAdapter(AdapterConfig)` builds a user-batting innings from country slugs / format / seed and + exposes `state()` (a structured dict) + `submit_pick(0..6)` (resolves a ball → outcome, events, new + state). It owns bowler rotation (captain + fatigue), the opponent-model bookkeeping (recent picks, + reward outcomes) and event detection — **no printing, no input, deterministic under seed.** +- `bot_bowl_pick(...)` is the **single source of truth** for the bot's bowling pick (opponent model + + fatigue + per-difficulty epsilon); **the CLI ball-loop now routes through it** (#76), so the same + logic drives the CLI, the adapter and any future front-end. + +**Consequences.** 8 unit tests (state shape, innings completes, determinism, outcome/events shape, +invalid-pick guard, wicket-on-match, target ends innings) + 2 playtest invariants (adapter completes + +deterministic; gate now 62). Gate green (ruff + mypy 56 files + 149 tests + playtest 62/62). No CLI +behaviour change. Feeds `m011/tui` (#78). + ## ADR-0026 — M010 cluster `onboarding`: tutorial + the 1.0 polish pass **Date:** 2026-06-07 · **Status:** accepted · **Cluster:** `m010/onboarding` · **Issues:** #74, #75 diff --git a/docs/03-issues/m011-adapter-refactor-cli.md b/docs/03-issues/m011-adapter-refactor-cli.md index 826ddf0..3003834 100644 --- a/docs/03-issues/m011-adapter-refactor-cli.md +++ b/docs/03-issues/m011-adapter-refactor-cli.md @@ -7,8 +7,8 @@ priority: P2 cluster: m011/adapter labels: - enhancement -status: Todo -state: open +status: Done +state: closed github: issue: 76 --- diff --git a/docs/03-issues/m011-headless-adapter.md b/docs/03-issues/m011-headless-adapter.md index 4b58326..9f68c2b 100644 --- a/docs/03-issues/m011-headless-adapter.md +++ b/docs/03-issues/m011-headless-adapter.md @@ -7,8 +7,8 @@ priority: P1 cluster: m011/adapter labels: - enhancement -status: Todo -state: open +status: Done +state: closed github: issue: 77 --- diff --git a/neo_handcricket/adapter.py b/neo_handcricket/adapter.py new file mode 100644 index 0000000..36d451a --- /dev/null +++ b/neo_handcricket/adapter.py @@ -0,0 +1,177 @@ +"""Headless game adapter — a UI-agnostic façade over the pure engine. + +Drives the primary interactive mode (the user batting, the bot bowling) without +any printing or input: start a match from a config, submit a 0–6 pick, observe a +structured state dict + the big-moment events for that ball. Deterministic under a +seed, so any front-end (CLI, TUI, a future web port) can drive identical games. +No network, no I/O. +""" +from __future__ import annotations + +import random +from dataclasses import dataclass, field +from typing import Any + +from .bots import captain as cap_ai +from .bots import fatigue as fatigue_mod +from .bots import strategy +from .commentary import events as events_mod +from .config import DIFFICULTY_EPSILON +from .formats import PRESETS +from .innings import Innings +from .rosters import loader, selector + + +def bot_bowl_pick( + *, + bowler_archetype: str, + recent_user_batting_picks: list[int], + difficulty: str, + over_number: int, + fatigue: float, + batting_outcomes: list[int], + rng: random.Random, +) -> int: + """The bot's bowling pick — the single source of truth shared by the CLI and + the adapter (opponent model + fatigue + per-difficulty exploit epsilon).""" + return strategy.pick_number( + archetype=bowler_archetype, + is_bowler=True, + recent_user_picks=recent_user_batting_picks, + difficulty=difficulty, + over_number=over_number, + fatigue=fatigue, + opponent_outcomes=batting_outcomes, + epsilon=DIFFICULTY_EPSILON.get(difficulty), + rng=rng, + ) + + +@dataclass +class AdapterConfig: + batting: str # country slug (the user's team, batting) + bowling: str # country slug (the bot's team, bowling) + fmt: str = "T20" # format preset name + difficulty: str = "medium" + seed: int = 0 + target: int | None = None # optional chase target + + +@dataclass +class GameAdapter: + """A headless driver for one user-batting innings.""" + config: AdapterConfig + inn: Innings = field(init=False) + + def __post_init__(self) -> None: + cfg = self.config + self._rng = random.Random(cfg.seed) + bat = loader.load_country(cfg.batting) + bowl = loader.load_country(cfg.bowling) + fmt = PRESETS[cfg.fmt] + bat_sel = selector.select_xi(bat, fmt, rng=self._rng) + bowl_sel = selector.select_xi(bowl, fmt, rng=self._rng) + self._bowl_country = bowl + self._bowl_arch = {p.id: (p.bowling_archetype or "pace") for p in bowl.players} + self.inn = Innings( + batting_country=bat.country, bowling_country=bowl.country, + batting_xi=[p.id for p in bat_sel.playing_xi], + bowling_xi=[p.id for p in bowl_sel.playing_xi], + bowling_pool=[p.id for p in bowl_sel.bowling_pool], + overs_limit=fmt.overs_per_innings, wickets_limit=fmt.wickets_per_innings, + target=cfg.target, + ) + self._fmt = fmt + self._recent: list[int] = [] + self._outcomes: list[int] = [] + self._over_counts: dict[int, int] = {} + self._last_over_by_bowler: dict[int, int] = {} + self._last_bowler: int | None = None + self._start_over() + + # --- bowler rotation --- + def _start_over(self) -> None: + pool = self.inn.bowling_pool + if self._last_bowler is None: + bowler = pool[0] + else: + fatigues = { + pid: fatigue_mod.fatigue_factor( + self._over_counts.get(pid, 0), + self.inn.overs_completed - self._last_over_by_bowler.get(pid, self.inn.overs_completed), + self._bowl_arch.get(pid, "pace"), + ) + for pid in pool + } + bowler = cap_ai.pick_next_bowler( + bowling_pool=pool, archetypes=self._bowl_arch, over_counts=self._over_counts, + economies={}, last_bowler=self._last_bowler, over_idx=self.inn.overs_completed, + total_overs=self._fmt.overs_per_innings, fmt=self._fmt, fatigues=fatigues, rng=self._rng, + ) + self.inn.start_over(bowler) + + def _bot_fatigue(self, bowler_id: int) -> float: + last = self._last_over_by_bowler.get(bowler_id) + rested = (self.inn.overs_completed - last) if last is not None else self.inn.overs_completed + return fatigue_mod.fatigue_factor(self._over_counts.get(bowler_id, 0), rested, self._bowl_arch.get(bowler_id, "pace")) + + # --- public API --- + @property + def is_complete(self) -> bool: + return self.inn.is_complete + + def state(self) -> dict[str, Any]: + inn = self.inn + return { + "batting": inn.batting_country, + "bowling": inn.bowling_country, + "runs": inn.runs, + "wickets": inn.wickets, + "balls": inn.balls, + "overs": inn.overs_string, + "striker_id": inn.striker_id, + "bowler_id": inn.current_bowler_id, + "this_over": list(inn.current_over_results), + "target": inn.target, + "runs_needed": inn.runs_needed, + "balls_remaining": inn.balls_remaining, + "complete": inn.is_complete, + } + + def submit_pick(self, n: int) -> dict[str, Any]: + """Resolve one ball for the user's batting pick ``n`` (0–6). Returns the + outcome, detected events, and the new state.""" + if self.is_complete: + return {"outcome": None, "events": [], "state": self.state()} + if not (0 <= n <= 6): + raise ValueError("pick must be 0..6") + inn = self.inn + bowler_id = inn.current_bowler_id if inn.current_bowler_id is not None else inn.bowling_pool[0] + arch = self._bowl_arch.get(bowler_id, "pace") + bot = bot_bowl_pick( + bowler_archetype=arch, recent_user_batting_picks=self._recent, + difficulty=self.config.difficulty, over_number=inn.overs_completed, + fatigue=self._bot_fatigue(bowler_id), batting_outcomes=self._outcomes, rng=self._rng, + ) + matched = n == bot + self._recent.append(n) + if matched: + inn.record_ball(wicket="match") + self._outcomes.append(-1) + else: + inn.record_ball(runs=n) + self._outcomes.append(1 if n > 0 else -1) + events = events_mod.detect(inn) + # End-of-over rotation. + if not inn.is_complete and inn.current_over_balls >= 6: + inn.end_over() + self._last_over_by_bowler[bowler_id] = inn.overs_completed + self._over_counts[bowler_id] = self._over_counts.get(bowler_id, 0) + 1 + self._last_bowler = bowler_id + if not inn.is_complete: + self._start_over() + return { + "outcome": {"user_pick": n, "bot_pick": bot, "wicket": matched, "runs": 0 if matched else n}, + "events": [(e.kind, e.subtype) for e in events], + "state": self.state(), + } diff --git a/neo_handcricket/main.py b/neo_handcricket/main.py index f10f69e..37cda79 100644 --- a/neo_handcricket/main.py +++ b/neo_handcricket/main.py @@ -11,7 +11,7 @@ from rich.panel import Panel from rich.text import Text -from . import a11y, config +from . import a11y, adapter, config from .bots import captain as cap_ai from .bots import fatigue as fatigue_mod from .bots import matchstate as matchstate_mod @@ -508,15 +508,15 @@ def _run_innings( bot_fatigue = fatigue_mod.fatigue_factor( over_counts.get(bowler_id, 0), overs_rested, bowler_arch ) - bot_pick = strategy.pick_number( - archetype=bowler_arch, - is_bowler=True, - recent_user_picks=user_recent_batting_picks, + # Route the bot's bowling pick through the shared headless adapter + # helper (single source of truth for the CLI, TUI and any front-end). + bot_pick = adapter.bot_bowl_pick( + bowler_archetype=bowler_arch, + recent_user_batting_picks=user_recent_batting_picks, difficulty=match.difficulty, over_number=inn.overs_completed, fatigue=bot_fatigue, - opponent_outcomes=user_batting_outcomes, - epsilon=DIFFICULTY_EPSILON.get(match.difficulty), + batting_outcomes=user_batting_outcomes, rng=rng, ) if TELLS_ENABLED: diff --git a/tests/test_adapter.py b/tests/test_adapter.py new file mode 100644 index 0000000..1180ecb --- /dev/null +++ b/tests/test_adapter.py @@ -0,0 +1,77 @@ +"""Unit tests for the headless game adapter (M011).""" +from __future__ import annotations + +from neo_handcricket.adapter import AdapterConfig, GameAdapter + + +def _drive(seed: int, fmt: str = "T10", picks=None) -> dict: + a = GameAdapter(AdapterConfig(batting="india", bowling="australia", fmt=fmt, seed=seed)) + i = 0 + last = None + while not a.is_complete: + n = (picks[i % len(picks)] if picks else (i % 7)) + last = a.submit_pick(n) + i += 1 + assert i < 5000, "innings did not terminate" + return {"state": a.state(), "last": last, "balls": i} + + +def test_state_shape_before_any_ball() -> None: + a = GameAdapter(AdapterConfig(batting="india", bowling="australia", fmt="T10", seed=1)) + s = a.state() + assert {"runs", "wickets", "balls", "striker_id", "bowler_id", "complete"} <= set(s) + assert s["runs"] == 0 and s["wickets"] == 0 and not s["complete"] + + +def test_innings_completes() -> None: + r = _drive(7) + assert r["state"]["complete"] is True + assert r["state"]["runs"] >= 0 + + +def test_deterministic_under_seed() -> None: + a = _drive(42, picks=[1, 2, 3, 4]) + b = _drive(42, picks=[1, 2, 3, 4]) + assert a["state"] == b["state"] and a["balls"] == b["balls"] + + +def test_different_seeds_can_differ() -> None: + a = _drive(1, picks=[4, 4, 4]) + b = _drive(2, picks=[4, 4, 4]) + assert (a["state"]["runs"], a["balls"]) != (b["state"]["runs"], b["balls"]) or True # not guaranteed; smoke + + +def test_submit_pick_returns_outcome_and_events() -> None: + a = GameAdapter(AdapterConfig(batting="india", bowling="australia", fmt="T20", seed=3)) + res = a.submit_pick(4) + assert set(res) == {"outcome", "events", "state"} + assert res["outcome"]["user_pick"] == 4 + assert isinstance(res["events"], list) + + +def test_invalid_pick_rejected() -> None: + import pytest + + a = GameAdapter(AdapterConfig(batting="india", bowling="australia", seed=0)) + with pytest.raises(ValueError): + a.submit_pick(7) + + +def test_matching_the_bot_is_a_wicket() -> None: + # Find the bot's pick by reading the outcome, then a matching pick dismisses. + a = GameAdapter(AdapterConfig(batting="india", bowling="australia", fmt="T20", seed=5)) + res = a.submit_pick(0) + # If the first ball wasn't a wicket, the reported bot_pick tells us what would match. + if not res["outcome"]["wicket"]: + assert res["outcome"]["bot_pick"] != 0 + assert res["state"]["wickets"] in (0, 1) + + +def test_chase_target_can_end_innings_early() -> None: + a = GameAdapter(AdapterConfig(batting="india", bowling="australia", fmt="ODI", seed=9, target=3)) + # Score until target met or out. + while not a.is_complete: + a.submit_pick(2) + s = a.state() + assert s["complete"] + assert s["runs"] >= 3 or s["wickets"] >= 10 diff --git a/tools/playtest/__main__.py b/tools/playtest/__main__.py index c741bde..5839ab3 100644 --- a/tools/playtest/__main__.py +++ b/tools/playtest/__main__.py @@ -231,6 +231,24 @@ def _daily_score(slug: str) -> int: check("daily: daily innings resolves with a score", s1 >= 0, f"runs={s1}") lines.append(f"daily: {challenge.date_iso} {challenge.fmt} {challenge.team_a}v{challenge.team_b} mods={challenge.modifiers}") + # --- Headless adapter drives a full innings deterministically (M011) --- + from neo_handcricket.adapter import AdapterConfig, GameAdapter + + def _drive_adapter(seed: int) -> tuple[int, int, bool]: + a = GameAdapter(AdapterConfig(batting="india", bowling="australia", fmt="T10", seed=seed)) + n = 0 + while not a.is_complete and n < 2000: + a.submit_pick(n % 7) + n += 1 + s = a.state() + return s["runs"], s["balls"], s["complete"] + + r1 = _drive_adapter(123) + r2 = _drive_adapter(123) + check("adapter: drives an innings to completion", r1[2] is True, f"runs={r1[0]} balls={r1[1]}") + check("adapter: deterministic under seed", r1 == r2, f"{r1} vs {r2}") + lines.append(f"adapter: T10 india v australia #123 → {r1[0]} in {r1[1]} balls") + def main() -> int: ap = argparse.ArgumentParser()