Files
dojo_cate/src/dojo/engine.py
T
eisterman 4998931cb0 Drop variant B, adopt Black-belt Exam defender, add one-sidedness metrics
- Remove variant B (Brown cap, +1 for higher beating lower) from rules,
  engine, bots and simulator; a higher belt beating a lower one gains 0.
- Final Exam: the defender fights as a Black belt (RULES.md v0.7). Ties to
  the defender and best-of-3 stay as simulator switches (`--exam`).
- Report stripe income per source as gains and losses.
- Add ten one-sidedness measures sampled at turn ends (lead gap, winner
  margin and lead share, halfway leader, last lead change, contention,
  rank swaps, leader Kumite losses, Exam challengers, winner pace).
- Plan: decisions D28-D33 and the detailed Phase 6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 17:09:42 +02:00

598 lines
22 KiB
Python

"""Rules engine: one game of Il Labirinto del Dojo.
Every choice a player makes is delegated to an ``Agent``; dice and deck order come from a
``Randomness`` source, so games are reproducible and tests can script every roll.
Rule references are to RULES.md.
"""
import itertools
import random
from collections import deque
from collections.abc import Sequence
from dataclasses import dataclass
from enum import Enum, auto
from functools import cache
from typing import Protocol
from dojo.board import QUADRANTS, Board, Pos, Square, is_tunnel
from dojo.events import (
CardDrawn,
Event,
ExamTaken,
FightKind,
GameOver,
KumiteFought,
Moved,
SentToStart,
Source,
Teleported,
TrackChanged,
TurnSkipped,
TurnStarted,
)
from dojo.movement import Path, walks
from dojo.rules import BLACK, TECNICHE, Card, RulesConfig
MIN_PLAYERS = 2
MAX_PLAYERS = 8
SENSEI_OSSERVA_GAIN = 2
class Randomness(Protocol):
def d6(self) -> int: ...
def shuffle(self, cards: list[Card]) -> None: ...
class RandomSource:
def __init__(self, seed: int | None = None) -> None:
self._rng = random.Random(seed)
def d6(self) -> int:
return self._rng.randint(1, 6)
def shuffle(self, cards: list[Card]) -> None:
self._rng.shuffle(cards)
@dataclass
class Player:
id: int
corner: int
position: Pos
track: int
tecnica: Card | None = None
meditating: bool = False
@dataclass(frozen=True)
class FightView:
"""What a fighter sees when deciding whether to play its Tecnica."""
attacker: int
defender: int
exam: bool
attacker_total: int
defender_total: int
played: tuple[tuple[int, Card], ...]
@dataclass(frozen=True)
class PlayerView:
"""What everyone at the table knows about a player: whether they hold a Tecnica, not which."""
id: int
corner: int
position: Pos
track: int
holds_tecnica: bool
meditating: bool
@dataclass(frozen=True)
class GameView:
"""The game as player ``me`` sees it: public state plus its own Tecnica (RULES.md, D26)."""
board: Board
config: RulesConfig
round: int
me: int
tecnica: Card | None
players: tuple[PlayerView, ...]
def belt(self, player: int) -> int:
return self.config.belt(self.players[player].track)
class Agent(Protocol):
def place_pawn(self, view: GameView, squares: Sequence[Pos]) -> Pos: ...
def choose_path(self, view: GameView, paths: Sequence[Path]) -> Path: ...
def use_tunnel(self, view: GameView, exit: Pos) -> bool: ...
def choose_tunnel_destination(self, view: GameView, tunnels: Sequence[Pos]) -> Pos: ...
def choose_sfida_target(self, view: GameView, targets: Sequence[int]) -> int: ...
def choose_irimi_target(self, view: GameView, targets: Sequence[int]) -> int: ...
def choose_exam_defender(self, view: GameView, candidates: Sequence[int]) -> int: ...
def keep_tecnica(self, view: GameView, held: Card, drawn: Card) -> Card: ...
def play_tecnica(self, view: GameView, fight: FightView) -> bool: ...
class Step(Enum):
MOVED = auto()
WON = auto()
LOST = auto()
EXAM = auto()
@dataclass(frozen=True)
class FightResult:
attacker_won: bool
kind: FightKind
attacker_uke: bool
defender_uke: bool
@cache
def seat_corners(players: int) -> tuple[int, ...]:
"""Corner of each seat, in turn order. Corner-mates are never consecutive (cyclically)."""
if not MIN_PLAYERS <= players <= MAX_PLAYERS:
raise ValueError(f"{players} players: supported range is {MIN_PLAYERS}-{MAX_PLAYERS}")
corners = sorted([*range(min(players, QUADRANTS)), *range(max(0, players - QUADRANTS))])
for order in itertools.permutations(corners):
if all(order[i] != order[(i + 1) % players] for i in range(players)):
return order
raise AssertionError(f"no seating for {players} players")
class Game:
def __init__(
self,
board: Board,
config: RulesConfig,
agents: Sequence[Agent],
randomness: Randomness,
) -> None:
self.board = board
self.config = config
self.agents = tuple(agents)
self.events: list[Event] = []
self.round = 1
self.current = 0
self.winner: int | None = None
self._random = randomness
self._dojo = board.dojo
cards = [card for card, quantity in config.deck for _ in range(quantity)]
randomness.shuffle(cards)
self._deck: deque[Card] = deque(cards)
self._discard: list[Card] = []
self.players = [
Player(seat, corner, min(board.safe_room(corner)), config.start_position)
for seat, corner in enumerate(seat_corners(len(self.agents)))
]
for player in self.players:
self._place(player)
@property
def finished(self) -> bool:
return self.winner is not None or self.round > self.config.max_rounds
def belt(self, player: int) -> int:
return self.config.belt(self.players[player].track)
def view(self, player: int) -> GameView:
me = self.players[player]
return GameView(
board=self.board,
config=self.config,
round=self.round,
me=player,
tecnica=me.tecnica,
players=tuple(
PlayerView(p.id, p.corner, p.position, p.track, p.tecnica is not None, p.meditating)
for p in self.players
),
)
def run(self) -> int | None:
while not self.finished:
self.play_turn()
return self.winner
def play_turn(self) -> None:
if self.finished:
raise RuntimeError("the game is over")
player = self.players[self.current]
if player.meditating:
player.meditating = False
self._emit(TurnSkipped(self.round, player.id))
else:
roll = self._random.d6() + self._random.d6()
self._emit(TurnStarted(self.round, player.id, roll))
self._move(player, roll)
if self.winner is not None:
self._emit(GameOver(self.winner, self.round))
return
self.current = (self.current + 1) % len(self.players)
if self.current == 0:
self.round += 1
if self.round > self.config.max_rounds:
self._emit(GameOver(None, self.config.max_rounds))
# Movement -----------------------------------------------------------------------------
def _move(self, player: Player, steps: int) -> None:
origin = player.position
visited = {origin}
remaining = steps
while True:
paths = self._path_choices(player, remaining, visited)
if not paths:
break
path = self._pick(
self._agent(player).choose_path(self.view(player.id), paths), paths, "path"
)
walked, step = self._walk(player, path, visited)
if step in (Step.LOST, Step.EXAM):
return
if step is not Step.WON or walked == len(path):
break
# A won Kumite may have changed the belt (and Dojo access): choose again.
remaining -= walked
if self._settle(player, visited):
self._resolve_square(player, origin)
def _walk(self, player: Player, path: Path, visited: set[Pos]) -> tuple[int, Step]:
"""Walk ``path`` until it ends or a Kumite happens. Returns (squares walked, last step)."""
step = Step.MOVED
walked = 0
for cell in path:
visited.add(cell)
walked += 1
step = self._step(player, cell, previous=player.position)
if step is not Step.MOVED:
break
self._emit(Moved(player.id, path[:walked]))
return walked, step
def _path_choices(self, player: Player, steps: int, visited: set[Pos]) -> list[Path]:
"""Legal walks, one per distinct outcome (end square and opponents crossed in order)."""
brown = self.config.is_brown(player.track)
choices: dict[tuple[Pos, tuple[Pos, ...]], Path] = {}
for path in walks(self.board, player.position, steps, visited, can_enter_dojo=brown):
crossed = tuple(cell for cell in path if self._fightable_at(cell, player))
choices.setdefault((path[-1], crossed), path)
return list(choices.values())
def _step(self, player: Player, cell: Pos, previous: Pos) -> Step:
if cell in self._dojo:
self._exam(player, entrance=previous)
return Step.EXAM
defender = self._fightable_at(cell, player)
player.position = cell
if defender is None:
return Step.MOVED
result = self._kumite(player, defender)
self._apply_fight(player, defender, result, attacker_retreat=previous)
return Step.WON if result.attacker_won else Step.LOST
def _settle(self, player: Player, visited: set[Pos]) -> bool:
"""Meditazione rule: a move may not end on another pawn's square (outside Safe Rooms).
Take one extra step while sharing a square; stay if no step is possible.
Returns whether the final square's effect should be resolved.
"""
while self._shares_square(player):
brown = self.config.is_brown(player.track)
options = walks(self.board, player.position, 1, visited, can_enter_dojo=brown)
if not options:
return True
path = self._pick(
self._agent(player).choose_path(self.view(player.id), options), options, "path"
)
_, step = self._walk(player, path, visited)
if step in (Step.LOST, Step.EXAM):
return False
return True
# Kumite -------------------------------------------------------------------------------
def _kumite(self, attacker: Player, defender: Player, *, exam: bool = False) -> FightResult:
config = self.config
defender_belt = config.belt(defender.track)
if exam and config.exam_defender_black:
defender_belt = BLACK
belts = (config.belt(attacker.track), defender_belt)
bonus = min(abs(belts[0] - belts[1]), config.max_belt_bonus)
dice = [self._random.d6(), self._random.d6()]
extra = [bonus if belts[0] > belts[1] else 0, bonus if belts[1] > belts[0] else 0]
uke = [False, False]
played: list[tuple[int, Card]] = []
for side, fighter in enumerate((attacker, defender)):
card = fighter.tecnica
if card is None:
continue
view = FightView(
attacker=attacker.id,
defender=defender.id,
exam=exam,
attacker_total=dice[0] + extra[0],
defender_total=dice[1] + extra[1],
played=tuple(played),
)
if not self._agent(fighter).play_tecnica(self.view(fighter.id), view):
continue
fighter.tecnica = None
self._discard.append(card)
played.append((fighter.id, card))
match card:
case Card.KIAI:
extra[side] += config.kiai_bonus
case Card.KAESHI:
dice[side] = self._random.d6()
case Card.UKE:
uke[side] = True
totals = (dice[0] + extra[0], dice[1] + extra[1])
if exam and config.exam_ties_to_defender:
attacker_won = totals[0] > totals[1]
else:
attacker_won = totals[0] >= totals[1] # Senshu: ties go to the attacker.
winner_belt, loser_belt = belts if attacker_won else belts[::-1]
if winner_belt == loser_belt:
kind = FightKind.EQUAL
elif winner_belt < loser_belt:
kind = FightKind.UPSET
else:
kind = FightKind.EXPECTED
self._emit(
KumiteFought(
attacker=attacker.id,
defender=defender.id,
attacker_belt=belts[0],
defender_belt=belts[1],
attacker_total=totals[0],
defender_total=totals[1],
attacker_won=attacker_won,
kind=kind,
exam=exam,
cards=tuple(played),
)
)
return FightResult(attacker_won, kind, attacker_uke=uke[0], defender_uke=uke[1])
def _apply_fight(
self, attacker: Player, defender: Player, result: FightResult, attacker_retreat: Pos
) -> None:
if result.attacker_won:
winner, loser, loser_uke = attacker, defender, result.defender_uke
else:
winner, loser, loser_uke = defender, attacker, result.attacker_uke
stripes = self.config.stripes_per_belt
if result.kind is not FightKind.EXPECTED:
self._change_track(winner, stripes, Source.KUMITE)
self._change_track(loser, -stripes, Source.KUMITE)
if not loser_uke:
self._send_to_start(loser)
elif loser is attacker:
self._relocate(attacker, attacker_retreat, "uke")
# Final Exam ---------------------------------------------------------------------------
def _exam(self, challenger: Player, entrance: Pos) -> None:
challenger.position = entrance # No pawn ever remains inside the Dojo.
eligible = [p for p in self.players if p is not challenger and not p.meditating]
if not eligible:
return
top = max(p.track for p in eligible)
candidates = [p.id for p in eligible if p.track == top]
if len(candidates) == 1:
defender_id = candidates[0]
else:
choice = self._agent(challenger).choose_exam_defender(
self.view(challenger.id), candidates
)
defender_id = self._pick(choice, candidates, "exam defender")
needed = self.config.exam_wins_needed
won = lost = 0
uke = False
while won < needed and lost < needed:
result = self._kumite(challenger, self.players[defender_id], exam=True)
uke = uke or result.attacker_uke
if result.attacker_won:
won += 1
else:
lost += 1
passed = won == needed
self._emit(ExamTaken(challenger.id, defender_id, passed))
if passed:
self.winner = challenger.id
return
self._change_track(challenger, -self.config.stripes_per_belt, Source.EXAM)
if not uke:
self._send_to_start(challenger)
# Squares ------------------------------------------------------------------------------
def _resolve_square(self, player: Player, origin: Pos) -> None:
code = self.board.code(player.position)
match code:
case Square.ALLENAMENTO:
self._change_track(player, 1, Source.SQUARE)
case Square.FATICA:
self._change_track(player, -1, Source.SQUARE)
case Square.NEBBIA:
self._draw(player, origin)
case Square.PASSO_FALSO:
self._relocate(player, origin, "passo falso")
case Square.SFIDA:
self._sfida(player)
case _ if is_tunnel(code):
exit_square = self.board.tunnels[player.position]
if not self._has_unfightable_pawn(exit_square, player) and self._agent(
player
).use_tunnel(self.view(player.id), exit_square):
self._teleport(player, exit_square, "tunnel")
def _sfida(self, player: Player) -> None:
targets = self._targets(player)
if not targets:
return
choice = self._agent(player).choose_sfida_target(self.view(player.id), targets)
defender = self.players[self._pick(choice, targets, "Sfida target")]
result = self._kumite(player, defender)
self._apply_fight(player, defender, result, attacker_retreat=player.position)
def _teleport(self, player: Player, destination: Pos, reason: str) -> None:
"""Arrive on ``destination`` without walking; an opponent there means a Kumite."""
source = player.position
defender = self._fightable_at(destination, player)
self._relocate(player, destination, reason)
if defender is not None:
result = self._kumite(player, defender)
self._apply_fight(player, defender, result, attacker_retreat=source)
# Nebbia del Dojo ----------------------------------------------------------------------
def _draw(self, player: Player, origin: Pos) -> None:
if not self._deck and self._discard:
self._random.shuffle(self._discard)
self._deck.extend(self._discard)
self._discard.clear()
if not self._deck:
return
card = self._deck.popleft()
self._emit(CardDrawn(player.id, card))
if card in TECNICHE:
self._take_tecnica(player, card)
return
self._discard.append(card)
match card:
case Card.TSUGI_ASHI:
self._move(player, self._random.d6() + self._random.d6())
case Card.TUNNEL_SEGRETO:
tunnels = sorted(
p
for p in self.board.tunnels
if p != player.position and not self._has_unfightable_pawn(p, player)
)
if tunnels:
choice = self._agent(player).choose_tunnel_destination(
self.view(player.id), tunnels
)
self._teleport(player, self._pick(choice, tunnels, "tunnel"), "tunnel segreto")
case Card.IRIMI:
self._irimi(player)
case Card.SFIDA:
self._sfida(player)
case Card.SENSEI_OSSERVA:
lowest = min(p.track for p in self.players)
for p in [p for p in self.players if p.track == lowest]:
self._change_track(p, SENSEI_OSSERVA_GAIN, Source.CARD)
case Card.PESO_DELLA_CINTURA:
highest = max(p.track for p in self.players)
for p in [p for p in self.players if p.track == highest]:
self._change_track(p, -1, Source.CARD)
case Card.ALLENAMENTO_INTENSIVO:
self._change_track(player, 1, Source.CARD)
case Card.COLPO_PROIBITO:
self._change_track(player, -1, Source.CARD)
case Card.PASSO_FALSO:
self._relocate(player, origin, "passo falso")
case Card.MEDITAZIONE:
player.meditating = True
def _take_tecnica(self, player: Player, drawn: Card) -> None:
held = player.tecnica
if held is None:
player.tecnica = drawn
return
choice = self._agent(player).keep_tecnica(self.view(player.id), held, drawn)
kept = self._pick(choice, (held, drawn), "Tecnica")
player.tecnica = kept
self._discard.append(drawn if kept is held else held)
def _irimi(self, player: Player) -> None:
targets = self._targets(player)
if not targets:
return
choice = self._agent(player).choose_irimi_target(self.view(player.id), targets)
other = self.players[self._pick(choice, targets, "Irimi target")]
mine, theirs = player.position, other.position
self._relocate(player, theirs, "irimi")
self._relocate(other, mine, "irimi")
# State helpers ------------------------------------------------------------------------
def _change_track(self, player: Player, delta: int, source: Source) -> None:
after = self.config.shifted(player.track, delta)
if after != player.track:
self._emit(TrackChanged(player.id, player.track, after, source))
player.track = after
def _place(self, player: Player) -> None:
squares = sorted(self.board.safe_room(player.corner))
choice = self._agent(player).place_pawn(self.view(player.id), squares)
player.position = self._pick(choice, squares, "start square")
def _send_to_start(self, player: Player) -> None:
self._place(player)
self._emit(SentToStart(player.id, player.position))
def _relocate(self, player: Player, destination: Pos, reason: str) -> None:
if destination != player.position:
self._emit(Teleported(player.id, player.position, destination, reason))
player.position = destination
def _in_safe_room(self, square: Pos) -> bool:
return self.board.code(square) == Square.SAFE
def _fightable_at(self, square: Pos, attacker: Player) -> Player | None:
if self._in_safe_room(square):
return None
for p in self.players:
if p is not attacker and p.position == square and not p.meditating:
return p
return None
def _has_unfightable_pawn(self, square: Pos, player: Player) -> bool:
return any(p is not player and p.position == square and p.meditating for p in self.players)
def _shares_square(self, player: Player) -> bool:
if self._in_safe_room(player.position):
return False
return any(p is not player and p.position == player.position for p in self.players)
def _targets(self, player: Player) -> list[int]:
return [
p.id
for p in self.players
if p is not player and not p.meditating and not self._in_safe_room(p.position)
]
def _agent(self, player: Player) -> Agent:
return self.agents[player.id]
def _emit(self, event: Event) -> None:
self.events.append(event)
@staticmethod
def _pick[T](choice: T, options: Sequence[T], what: str) -> T:
if choice not in options:
raise ValueError(f"agent chose an illegal {what}: {choice!r}")
return choice