stateDiagram-v2 [*] --> LeftTurn LeftTurn --> LeftTurn: unaffordable move rejected LeftTurn --> RightTurn: accepted move and both active RightTurn --> RightTurn: unaffordable move rejected RightTurn --> LeftTurn: accepted move and both active LeftTurn --> Finished: accepted move lowers shield to zero RightTurn --> Finished: accepted move lowers shield to zero Finished --> Finished: later turn rejected
Unit Challenge: Run the Tiny Robot Tournament
1. Enter the Tiny Robot Tournament
The workshop lights dim. Bolt and Nova roll into a tabletop arena, each with an energy meter and a shield. A move costs the acting robot energy and reduces the opponent’s shield. Accepted turns alternate between the two robots. The first robot to reduce the other shield to zero wins.
Your model must answer every turn with data, not print statements. A notebook, CLI, or graphical interface can decide later how to announce the result.
The challenge is deliberately smaller than a commercial battle system. There are no inventories, teams, animations, artificial intelligence, or networked players. Those features would hide the object-design decisions beneath unrelated work. The fun comes from making a compact rules engine behave exactly as the thirty checks predict.
After the base tournament works, add the combo spotlight: when the same robot successfully repeats the same named move on its next turn, that move applies one extra point of impact. Rejected turns do not change the active robot, spend energy, damage a shield, or update combo history.
You do not need inheritance. Arena composes two Robot objects and uses immutable Move and TurnResult values. Choosing not to create AttackRobot, DefendRobot, or ComboRobot subclasses is part of the design.
Implement the types in dependency order: Move, Robot, TurnResult, then Arena. Run the check harness after each stage. The first failure names the earliest contract that still needs work.
2. Translate the tournament rules into contracts
Move is an immutable value
- Strip surrounding whitespace from
nameand reject a blank result. energy_costandimpactare non-negative integers; booleans do not count as integers for this model.- A move cannot have both zero cost and zero impact.
- Two moves with the same normalized field values compare equal.
- Ordinary field assignment after construction is rejected.
Robot owns valid changing state
- Strip
name; reject blank names. - Initial
energyandshieldare integers from 1 through 20. - Expose read-only
energy,shield, andactiveproperties. spend_energy(amount)accepts a non-negative integer no greater than current energy, then subtracts it.absorb_impact(amount)accepts a non-negative integer and reduces shield with a floor at zero.- Every rejected operation leaves the robot unchanged.
- A robot is active exactly while shield is greater than zero.
TurnResult is immutable evidence
Record:
- actor and target names;
- normalized move name;
- applied impact, including any combo point;
- whether the spotlight combo activated;
- actor energy after spending;
- target shield after impact; and
- status:
"running"or"finished".
Arena coordinates the workflow
- Receive two different robot objects with different names.
- The left robot acts first; accepted turns alternate.
- Expose the current robot as a read-only property.
winnerisNonewhile both shields are active and the winning robot’s name after completion.- Reject a turn after completion.
- Reject a move the actor cannot afford before changing any object or history.
- On success: calculate combo, spend energy, apply impact, record history, advance the active side, and return
TurnResult. - Combo history belongs to the arena because it compares successful turns across time; neither
MovenorRobotneeds tournament-specific history.
Rejected requests return to the same state. Only an accepted turn can update robots, history, and the current side.
3. Start from the contract
Copy this starter into one cell or Python file. Replace each ... without renaming the public classes, properties, or methods.
from dataclasses import dataclass
@dataclass(frozen=True)
class Move:
"""A named tournament move with an energy cost and shield impact."""
name: str
energy_cost: int
impact: int
def __post_init__(self):
...
class Robot:
"""A tournament robot that protects its energy and shield state."""
def __init__(self, name, *, energy=10, shield=10):
...
@property
def energy(self):
...
@property
def shield(self):
...
@property
def active(self):
...
def spend_energy(self, amount):
"""Spend an affordable non-negative amount and return energy left."""
...
def absorb_impact(self, amount):
"""Reduce shield to no less than zero and return shield left."""
...
@dataclass(frozen=True)
class TurnResult:
"""An immutable report of one accepted tournament turn."""
actor: str
target: str
move: str
impact: int
combo: bool
energy_left: int
shield_left: int
status: str
class Arena:
"""Coordinate two robots and the rules for accepted tournament turns."""
def __init__(self, left, right):
...
@property
def current_robot(self):
...
@property
def winner(self):
...
def play_turn(self, move):
"""Apply one affordable move atomically and return its result."""
...Do not add input, print, randomness, files, or global tournament state to these classes. You may add private helper methods or attributes when they make one responsibility clearer.
4. Build one responsibility at a time
Work through four short stages:
- Move values: normalize and validate
Move, then prove equality, hashability, and frozen assignment before touching robot state. - Robot invariants: construct one robot, spend energy, absorb impact, and prove every rejected operation preserves the before snapshot.
- Base arena: compose two fresh robots, process one turn from each side, reject unaffordable work, and finish a tournament.
- Combo spotlight: remember the last successful move independently for each robot, then prove rejected work never enters that history.
After a failure, keep the smallest stage in view. A broken Move invariant cannot be repaired by changing turn alternation, and an incorrect winner should not lead you to rewrite dataclass equality.
5. Run progressive assertions
Run this harness below your implementation. It deliberately uses ordinary assertions rather than pytest.
from dataclasses import FrozenInstanceError
checks_run = 0
def check(condition, message):
"""Count one passing tournament contract or raise its message."""
global checks_run
assert condition, message
checks_run += 1
def raises(exception_type, action):
"""Return whether calling action raises the expected exception type."""
try:
action()
except exception_type:
return True
return False
# Move contracts: checks 1–7
pulse = Move(" Pulse ", energy_cost=2, impact=3)
check(pulse == Move("Pulse", 2, 3), "Move should normalize and compare by value")
check(pulse in {Move("Pulse", 2, 3)}, "a fully hashable frozen Move should hash")
def rename_pulse():
pulse.name = "Other"
check(raises(FrozenInstanceError, rename_pulse), "Move should be frozen")
check(raises(ValueError, lambda: Move(" ", 1, 1)), "blank move should fail")
check(raises(ValueError, lambda: Move("Bad", -1, 1)), "negative cost should fail")
check(raises(ValueError, lambda: Move("Bad", 1, -1)), "negative impact should fail")
check(raises(ValueError, lambda: Move("Nap", 0, 0)), "a move must do something")
# Robot contracts: checks 8–15
probe = Robot(" Bolt ", energy=10, shield=8)
check(
(probe.name, probe.energy, probe.shield) == ("Bolt", 10, 8),
"Robot should normalize its name and expose initial state",
)
check(probe.active is True, "a positive shield should be active")
check(probe.spend_energy(3) == 7, "spending should return remaining energy")
check(
probe.absorb_impact(20) == 0 and probe.active is False,
"impact should floor shield at zero and deactivate the robot",
)
probe_state = (probe.energy, probe.shield)
check(
raises(ValueError, lambda: probe.spend_energy(8)),
"overspending should fail",
)
check((probe.energy, probe.shield) == probe_state, "failed spending should be atomic")
check(
raises(ValueError, lambda: probe.absorb_impact(-1)),
"negative impact should fail",
)
def assign_energy():
probe.energy = 99
check(raises(AttributeError, assign_energy), "energy should be read-only")
# Arena and combo contracts: checks 16–30
solo = Robot("Solo")
check(raises(ValueError, lambda: Arena(solo, solo)), "one object cannot fill both slots")
check(
raises(ValueError, lambda: Arena(Robot("Echo"), Robot(" Echo "))),
"robot names must differ",
)
left = Robot("Bolt", energy=8, shield=8)
right = Robot("Nova", energy=8, shield=8)
arena = Arena(left, right)
check(arena.current_robot is left and arena.winner is None, "left should act first")
jab = Move("Jab", energy_cost=2, impact=3)
first = arena.play_turn(jab)
check(
first == TurnResult("Bolt", "Nova", "Jab", 3, False, 6, 5, "running"),
"first turn result is wrong",
)
check(
(left.energy, right.shield, arena.current_robot) == (6, 5, right),
"first turn state or alternation is wrong",
)
def rewrite_result():
first.combo = True
check(raises(FrozenInstanceError, rewrite_result), "TurnResult should be frozen")
spark = Move("Spark", energy_cost=1, impact=2)
second = arena.play_turn(spark)
check(
second == TurnResult("Nova", "Bolt", "Spark", 2, False, 7, 6, "running"),
"second robot turn is wrong",
)
before_rejection = (
left.energy,
left.shield,
right.energy,
right.shield,
arena.current_robot,
)
check(
raises(ValueError, lambda: arena.play_turn(Move("Nova Beam", 99, 1)))
and before_rejection
== (
left.energy,
left.shield,
right.energy,
right.shield,
arena.current_robot,
),
"an unaffordable turn should change nothing",
)
third = arena.play_turn(jab)
check(
third == TurnResult("Bolt", "Nova", "Jab", 4, True, 4, 1, "running"),
"Bolt's repeated move should receive the combo point",
)
fourth = arena.play_turn(spark)
check(
fourth == TurnResult("Nova", "Bolt", "Spark", 3, True, 6, 3, "running"),
"Nova's repeated move should track its own combo",
)
final = arena.play_turn(jab)
check(
final == TurnResult("Bolt", "Nova", "Jab", 4, True, 2, 0, "finished")
and arena.winner == "Bolt"
and right.active is False,
"finishing turn or winner is wrong",
)
finished_state = (left.energy, left.shield, right.energy, right.shield, arena.winner)
check(
raises(RuntimeError, lambda: arena.play_turn(Move("Tap", 0, 1)))
and finished_state
== (left.energy, left.shield, right.energy, right.shield, arena.winner),
"a finished arena should reject later turns without mutation",
)
other_left = Robot("Tin", energy=4, shield=4)
other_right = Robot("Copper", energy=4, shield=4)
other_arena = Arena(other_left, other_right)
check(
other_arena.current_robot is other_left
and (other_left.energy, other_right.shield) == (4, 4),
"a separate arena should own independent participants and turn state",
)
low = Robot("Low", energy=1, shield=4)
steady = Robot("Steady", energy=4, shield=4)
rejection_arena = Arena(low, steady)
rejection_state = (low.energy, steady.shield, rejection_arena.current_robot)
check(
raises(ValueError, lambda: rejection_arena.play_turn(Move("Heavy", 2, 2)))
and rejection_state
== (low.energy, steady.shield, rejection_arena.current_robot),
"rejection should not advance state or current robot",
)
tap = rejection_arena.play_turn(Move("Tap", 0, 1))
check(
tap.combo is False and tap.impact == 1 and rejection_arena.current_robot is steady,
"a rejected move must not create combo history",
)
print(f"Tournament ready: {checks_run} checks passed")Expected final output:
Checks 1–7: move values
Checks 8–15: robot invariants
Checks 16–30: arena composition and history
6. Use the hint ladder only when needed
Hint 1
Map responsibilities before filling methods:
Normalize frozen Move.name in __post_init__ with object.__setattr__(self, "name", cleaned). Store robot state in _energy and _shield, then expose read-only properties.
Hint 2
In play_turn, choose actor and target from a two-item tuple. Before mutation, check completion and affordability. A dictionary keyed by each robot object can store its last successful normalized move name. Combo is last_moves.get(actor) == move.name.
Do not write history or change the active index until validation has passed.
Hint 3
The successful order is:
- reject a finished arena;
- select actor and target;
- reject an unaffordable move;
- calculate
comboandapplied_impact; - call
actor.spend_energy; - call
target.absorb_impact; - remember the actor’s move;
- alternate the current index;
- derive status from
winner; and - return all final values in
TurnResult.
Clamp shield inside Robot.absorb_impact with max(0, ...).
7. Keep debugging evidence
Record one failure that improved your understanding:
| Evidence | Your record |
|---|---|
| Earliest failing check | number and exact assertion message |
| Before state | actor energy, both shields, current robot, and known winner |
| Expected contract | one sentence describing what should happen |
| Hypothesis | the specific validation, ordering, or ownership rule involved |
| Controlled repair | one method or responsibility changed |
| After state | passing check plus one nearby regression check |
For example, if an unaffordable turn switches the current robot, the defect is not in Robot.spend_energy: the arena advanced its index before completing validation. If a repeated move never gains impact, inspect when and under which robot key successful history is stored. Preserve the failing observation rather than replacing it with “it did not work.”
8. Compare a complete tournament model
Show the complete implementation after attempting all stages
from dataclasses import dataclass
def require_non_negative_integer(value, label):
"""Validate one tournament count."""
if not isinstance(value, int) or isinstance(value, bool):
raise TypeError(f"{label} must be an integer")
if value < 0:
raise ValueError(f"{label} cannot be negative")
@dataclass(frozen=True)
class Move:
"""A named tournament move with an energy cost and shield impact."""
name: str
energy_cost: int
impact: int
def __post_init__(self):
if not isinstance(self.name, str):
raise TypeError("name must be a string")
cleaned = self.name.strip()
if not cleaned:
raise ValueError("name cannot be blank")
require_non_negative_integer(self.energy_cost, "energy_cost")
require_non_negative_integer(self.impact, "impact")
if self.energy_cost == 0 and self.impact == 0:
raise ValueError("a move must cost energy or apply impact")
object.__setattr__(self, "name", cleaned)
class Robot:
"""A tournament robot that protects its energy and shield state."""
def __init__(self, name, *, energy=10, shield=10):
if not isinstance(name, str):
raise TypeError("name must be a string")
cleaned = name.strip()
if not cleaned:
raise ValueError("name cannot be blank")
require_non_negative_integer(energy, "energy")
require_non_negative_integer(shield, "shield")
if not 1 <= energy <= 20:
raise ValueError("energy must be between 1 and 20")
if not 1 <= shield <= 20:
raise ValueError("shield must be between 1 and 20")
self.name = cleaned
self._energy = energy
self._shield = shield
@property
def energy(self):
return self._energy
@property
def shield(self):
return self._shield
@property
def active(self):
return self._shield > 0
def spend_energy(self, amount):
require_non_negative_integer(amount, "amount")
if amount > self._energy:
raise ValueError("not enough energy")
self._energy -= amount
return self._energy
def absorb_impact(self, amount):
require_non_negative_integer(amount, "amount")
self._shield = max(0, self._shield - amount)
return self._shield
@dataclass(frozen=True)
class TurnResult:
"""An immutable report of one accepted tournament turn."""
actor: str
target: str
move: str
impact: int
combo: bool
energy_left: int
shield_left: int
status: str
class Arena:
"""Coordinate two robots and the rules for accepted tournament turns."""
def __init__(self, left, right):
if not isinstance(left, Robot) or not isinstance(right, Robot):
raise TypeError("left and right must be Robot instances")
if left is right:
raise ValueError("left and right must be different objects")
if left.name == right.name:
raise ValueError("robot names must differ")
self._robots = (left, right)
self._current_index = 0
self._last_moves = {}
@property
def current_robot(self):
return self._robots[self._current_index]
@property
def winner(self):
left, right = self._robots
if not left.active:
return right.name
if not right.active:
return left.name
return None
def play_turn(self, move):
if not isinstance(move, Move):
raise TypeError("move must be a Move")
if self.winner is not None:
raise RuntimeError("tournament is already finished")
actor = self._robots[self._current_index]
target = self._robots[1 - self._current_index]
if move.energy_cost > actor.energy:
raise ValueError("not enough energy for this move")
combo = self._last_moves.get(actor) == move.name
applied_impact = move.impact + (1 if combo else 0)
actor.spend_energy(move.energy_cost)
target.absorb_impact(applied_impact)
self._last_moves[actor] = move.name
self._current_index = 1 - self._current_index
status = "finished" if self.winner is not None else "running"
return TurnResult(
actor=actor.name,
target=target.name,
move=move.name,
impact=applied_impact,
combo=combo,
energy_left=actor.energy,
shield_left=target.shield,
status=status,
)Run the unchanged thirty-check harness below this implementation. Notice the responsibility boundaries:
- the validation helper owns a repeated primitive rule, not object state;
MoveandTurnResultget field equality and frozen behavior;Robotis a regular class because it owns guarded mutation and identity; andArenacomposes robots and owns tournament order/history.
9. Explain the combo change
The base battle needs current robot, energy, shield, and winner. Combo adds one new question: “Which successful move did this same actor use on its previous personal turn?”
That history belongs to Arena because:
- a move value should describe one move, not remember who used it;
- a robot may participate in a future game with different tournament rules;
- the arena already owns alternation and accepted-turn order; and
- rejected turns must not enter successful tournament history.
The dictionary uses robot objects as keys. Regular Robot instances keep identity-based hashing and equality because the class does not define __eq__. Two robots named Bolt are still different objects, although this arena also rejects duplicate display names to keep results unambiguous.
Try one more transfer variation after all checks pass:
- add a third move named
"Guard Tap"with zero energy cost and one impact; - confirm the first use is not a combo;
- let the other robot take an accepted turn;
- repeat
"Guard Tap"and confirm impact becomes two; and - replace it with a different move on the next personal turn and confirm the combo resets.
This modification needs no subclass. It changes arena-owned history and result evidence while robot invariants remain untouched.
10. Draw the responsibility map
Complete this short record in your own words:
| Question | Your explanation |
|---|---|
| Which types are value objects? | |
| Which type is a stateful entity? | |
| Which type orchestrates collaborators? | |
| Which function remains a plain function, and why? | |
| Why is inheritance unnecessary? | |
| Which fields must remain unchanged after rejection? | |
| Why does combo history belong to the arena? |
A strong explanation names observable contracts. “Arena is the manager” is too vague. “Arena owns which robot acts, successful move history, winner calculation, and the order of atomic mutation” is specific enough to review.
11. Check your understanding
12. Record the challenge result
Record completion after:
- all thirty checks pass in one clean run;
- your implementation contains no input, printing, global tournament state, or unnecessary project-defined inheritance;
- an unaffordable move and a finished-game move both preserve prior state;
- both competitors can earn separate combos;
- the result values exactly report base and combo impact;
- the additional
Guard Tapvariation behaves as predicted; and - your responsibility map explains the choices in behavioral terms.
This button stores progress only in this browser. It does not submit code, grade identity, or issue a certificate.
Not yet recorded.
Key points
- Immutable dataclasses fit move descriptions and accepted-turn evidence because their declared fields determine value semantics.
- A regular robot class owns identity and guarded mutation. Read-only properties expose state without offering arbitrary replacement.
- The arena composes two robots and owns the rules that cross them: active side, affordability-before-commit, winner, and successful-move history.
- Rejected operations are atomic: no energy, shield, current side, winner, or combo fact changes.
- The combo rule belongs to tournament history and can evolve without a robot subclass.
- Progressive assertions let a learner locate the earliest broken contract and continue without a testing framework.