Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/00-overview/decision-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/03-issues/m011-adapter-refactor-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ priority: P2
cluster: m011/adapter
labels:
- enhancement
status: Todo
state: open
status: Done
state: closed
github:
issue: 76
---
Expand Down
4 changes: 2 additions & 2 deletions docs/03-issues/m011-headless-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ priority: P1
cluster: m011/adapter
labels:
- enhancement
status: Todo
state: open
status: Done
state: closed
github:
issue: 77
---
Expand Down
177 changes: 177 additions & 0 deletions neo_handcricket/adapter.py
Original file line number Diff line number Diff line change
@@ -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(),
}
14 changes: 7 additions & 7 deletions neo_handcricket/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
77 changes: 77 additions & 0 deletions tests/test_adapter.py
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions tools/playtest/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading