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>
This commit is contained in:
2026-09-26 17:09:42 +02:00
parent 7b76b10175
commit 4998931cb0
16 changed files with 707 additions and 256 deletions
+25 -12
View File
@@ -31,7 +31,7 @@ from dojo.events import (
TurnStarted,
)
from dojo.movement import Path, walks
from dojo.rules import TECNICHE, Card, RulesConfig
from dojo.rules import BLACK, TECNICHE, Card, RulesConfig
MIN_PLAYERS = 2
MAX_PLAYERS = 8
@@ -308,7 +308,10 @@ class Game:
def _kumite(self, attacker: Player, defender: Player, *, exam: bool = False) -> FightResult:
config = self.config
belts = (config.belt(attacker.track), config.belt(defender.track))
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]
@@ -341,7 +344,10 @@ class Game:
uke[side] = True
totals = (dice[0] + extra[0], dice[1] + extra[1])
attacker_won = totals[0] >= totals[1] # Senshu: ties go to the attacker.
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
@@ -374,9 +380,7 @@ class Game:
winner, loser, loser_uke = defender, attacker, result.attacker_uke
stripes = self.config.stripes_per_belt
if result.kind is FightKind.EXPECTED:
self._change_track(winner, self.config.expected_win_gain, Source.KUMITE)
else:
if result.kind is not FightKind.EXPECTED:
self._change_track(winner, stripes, Source.KUMITE)
self._change_track(loser, -stripes, Source.KUMITE)
@@ -402,13 +406,23 @@ class Game:
)
defender_id = self._pick(choice, candidates, "exam defender")
result = self._kumite(challenger, self.players[defender_id], exam=True)
self._emit(ExamTaken(challenger.id, defender_id, result.attacker_won))
if result.attacker_won:
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 result.attacker_uke:
if not uke:
self._send_to_start(challenger)
# Squares ------------------------------------------------------------------------------
@@ -525,8 +539,7 @@ class Game:
# State helpers ------------------------------------------------------------------------
def _change_track(self, player: Player, delta: int, source: Source) -> None:
from_board = source in (Source.SQUARE, Source.CARD)
after = self.config.shifted(player.track, delta, from_board=from_board)
after = self.config.shifted(player.track, delta)
if after != player.track:
self._emit(TrackChanged(player.id, player.track, after, source))
player.track = after