Dieselbe Simulation wie in der ersten Ausbaustufe, einmal gründlich refaktorisiert. Das Spiel verhält sich fast gleich — was sich geändert hat, ist die Frage, woran man später noch weiterbauen kann.
Damit ist dieses Notebook zugleich das erste Brownfield-Refactoring im Skript, nur mit dem Sonderfall, dass der alte Code von uns selbst stammt: Man liest fremden Code, erkennt die Muster, die nicht mehr tragen, und ersetzt sie schrittweise. Dasselbe Vorgehen wenden Sie in den Übungen auf Wesen und BamBam an.
import sys
import os
from dataclasses import dataclass, field
from itertools import cycle
from enum import Enum, auto
from typing import Optional
class GameException(Exception):
"""Base class for all game-related errors."""
pass
class NotEnoughMoneyException(GameException):
pass
class NotEnoughStockException(GameException):
pass
class ProductType(Enum):
FOOD = auto()
# Später auch andere Produkte
def __str__(self):
return self.name.title()
@dataclass
class Inventory:
"""Each player has one inventory"""
money: float
stock: dict[ProductType, int] = field(default_factory=dict)
def execute_trade(self, product: ProductType, quantity: int, unit_price: float):
"""
Unified method for Buying AND Selling.
- Positive quantity = BUY (Money down, Stock up)
- Negative quantity = SELL (Money up, Stock down)
"""
cost = quantity * unit_price
# 1. Validation Logic
if quantity > 0: # BUYING
if self.money < cost:
raise NotEnoughMoneyException(
f"Need {cost:.2f}€, have {self.money:.2f}€"
)
elif quantity < 0: # SELLING
# check current stock. absolute value needed because quantity is negative
current_stock = self.stock.get(product, 0)
if current_stock < abs(quantity):
raise NotEnoughStockException(f"Not enough {product.name} to sell.")
# 2. Execution Logic (Only runs if Validation passes)
self.money -= cost
current_qty = self.stock.get(product, 0)
self.stock[product] = current_qty + quantity
def __str__(self) -> str:
"""Creates a pretty, human-readable string for the player."""
money_display = f"{self.money:.2f} €"
items_list = [
f"{product.name.title()}: {amount}"
for product, amount in self.stock.items()
if amount > 0
]
stock_display = ", ".join(items_list) if items_list else "Empty"
return f"[Money: {money_display} | Stock: {stock_display}]"
@dataclass
class Market:
"""For each product type, there is one market"""
product: ProductType
current_price: float
volatility: float
def adjust_price(self, direction: int) -> None:
"""
Adjusts price based on supply/demand.
direction: 1 for Buy (Demand up), -1 for Sell (Supply up).
"""
change = direction * self.volatility
new_price = self.current_price + change
self.current_price = max(1.0, round(new_price, 2))Warum diese Umbauten¶
Die Zelle oben enthält drei Refactorings, die selten einzeln vorkommen. Sie hängen zusammen, und jedes beantwortet eine Frage, die in practical-0 offen blieb.
Von freien Variablen zu @dataclass. Inventory und Market fassen das zusammen, was vorher als money, stock, price und volatility nebeneinanderlag. Der Gewinn ist nicht die kürzere Schreibweise, sondern dass es den Zustand jetzt als Objekt gibt: Man kann ihn erzeugen, kopieren, in einen Test hineingeben und mehrfach haben. Der Dekorator erzeugt dabei __init__, __repr__ und __eq__; field(default_factory=dict) ist die Antwort auf die klassische Falle, dass ein veränderliches Default-Argument sonst von allen Instanzen geteilt würde. Siehe Dataclasses.
Von Strings zu enum.Enum. Vorher war das Produkt implizit — es gab genau eins, “Food”, und es stand als Text in der Ausgabe. ProductType.FOOD macht daraus einen Wert, den man nicht vertippen kann: ProductType.FOD ist ein AttributeError beim Start, "fod" wäre ein stiller Fehler zur Laufzeit. Der Kommentar # Später auch andere Produkte ist der eigentliche Punkt: Das Hinzufügen eines zweiten Produkts kostet jetzt eine Zeile. Siehe Enums.
Von einem else-Zweig zu eigenen Exceptions. NotEnoughMoneyException und NotEnoughStockException erben beide von GameException. Damit kann der Aufrufer wählen, wie genau er hinsehen will: except NotEnoughMoneyException behandelt den einen Fall, except GameException alle Spielfehler gemeinsam, und ein Programmierfehler — etwa ein TypeError — rutscht nicht versehentlich mit durch, wie es bei einem except Exception passiert wäre. Genauso wichtig: execute_trade prüft erst alles und bucht dann. Wäre es umgekehrt, bliebe nach einer Exception ein halb gebuchter Zustand zurück. Siehe Exceptions.
class EconomyEngine:
def __init__(self):
# 1. Initialize State
self.player = Inventory(money=100.0)
self.food_market = Market(
product=ProductType.FOOD, current_price=10.0, volatility=0.5
)
self.history: list[tuple] = []
self.market_trend = cycle([0.2, 0.2, -0.1, -0.3])
def interact_with_market(self, is_buy: bool, quantity: int) -> tuple[str, float]:
"""
Executes the trade logic.
Returns details of the transaction (Action Name, Total Price)
or raises an exception.
"""
market = self.food_market
# Math: Buy is positive quantity, Sell is negative
signed_qty = quantity if is_buy else -quantity
# 1. Execute logic (Raises exceptions if invalid)
self.player.execute_trade(market.product, signed_qty, market.current_price)
# 2. Update History
action_name = "BUY" if is_buy else "SELL"
self.history.append((action_name, quantity, market.current_price))
# 3. Update Market Price (Supply/Demand)
# +1 direction for Buy, -1 for Sell
direction = 1 if is_buy else -1
market.adjust_price(direction)
return action_name, market.current_price
def tick(self):
"""Advances the simulation by one step (Background market forces)."""
trend = next(self.market_trend)
new_price = self.food_market.current_price + trend
self.food_market.current_price = max(1.0, round(new_price, 2))class TerminalUI:
def __init__(self, engine: EconomyEngine):
self.engine = engine
def parse_command(self, raw_input: str) -> tuple[str, int]:
"""Parses 'b 5' into ('b', 5). Handles errors internally."""
parts = raw_input.split()
if not parts:
raise ValueError("Empty command")
command = parts[0]
amount = int(parts[1]) if len(parts) > 1 else 1
return command, amount
def render(self):
"""Visualizes the Engine state."""
# Accessing data from the Engine to display it
m = self.engine.food_market
print("-" * 40)
print(f"MARKET: {m.product.name.title()} @ {m.current_price:.2f} €")
print("-" * 40)
print(self.engine.player) # Uses Inventory.__str__
print("-" * 40)
# Formatting the raw history data for the user
last_3 = self.engine.history[-3:]
print(f"History: {last_3}")
print("-" * 40)
print("COMMANDS: 'b 1' (buy), 's 5' (sell), 'q' (quit)")
def run(self):
while True:
self.render()
user_input = input(">> ").strip().lower()
if user_input == "q":
print("Exiting...")
break
try:
cmd, amount = self.parse_command(user_input)
if cmd == "b":
action, price = self.engine.interact_with_market(
is_buy=True, quantity=amount
)
print(f"\n[SUCCESS] {action} {amount} units @ {price:.2f}\n")
elif cmd == "s":
action, price = self.engine.interact_with_market(
is_buy=False, quantity=amount
)
print(f"\n[SUCCESS] {action} {amount} units @ {price:.2f}\n")
else:
print("\n[!] Unknown command.\n")
# Advance the game world
self.engine.tick()
except ValueError:
print("\n[!] Invalid input format. Use 'b 5' or 's 1'.\n")
except GameException as e:
# Catching the logic errors from the Engine
print(f"\n[!] TRANSACTION FAILED: {e}\n")Der Schnitt zwischen Engine und UI¶
EconomyEngine rechnet und druckt nicht. TerminalUI druckt und rechnet nicht. Diese Trennung ist die folgenreichste Entscheidung im ganzen Notebook, und sie zahlt sich dreifach aus:
Mehrere Oberflächen. Dieselbe Engine kann hinter einer Terminal-Ausgabe, einer Textual-Oberfläche oder einer HTTP-API stehen — alles im Kapitel User Interfaces. Ohne den Schnitt müsste man die Spielregeln für jede Oberfläche neu schreiben.
Testbarkeit.
interact_with_marketnimmt Zahlen entgegen und gibt ein Tupel zurück oder wirft. So etwas testet man in drei Zeilen. Die Schleife aus practical-0 dagegen ließe sich nur testen, indem maninputersetzt undstdoutabfängt — machbar (siehe Testing), aber ungleich mühsamer für dasselbe Ergebnis.Fehler haben einen Ort. Die Engine wirft, die UI fängt und formuliert. Die Regel “was schiefgehen darf” und die Regel “wie es dem Menschen erklärt wird” stehen nicht mehr in derselben Zeile.
Ein Detail, das leicht übersehen wird: parse_command gehört zur UI, nicht zur Engine. Das Zerlegen von "b 5" in ("b", 5) ist Eingabeverarbeitung; die Engine kennt nur is_buy und quantity. Wer diese Grenze verschiebt, hat die Trennung dem Namen nach, aber nicht in der Sache.
Ein bewusster Unterschied im Preismodell¶
Die Simulation ist nicht in jeder Hinsicht identisch zu practical-0 — an einer Stelle wurde die Spielregel absichtlich geändert:
| practical-0 | practical-1 | |
|---|---|---|
| Preisbewegung durch einen Handel | price += 0.5 * amount — proportional zur Menge | change = direction * volatility — feste ±0.5, unabhängig von der Menge |
In practical-0 verschiebt der Kauf von 10 Einheiten den Preis um 5.00 €, in practical-1 um 0.50 €. Das ist kein Übertragungsfehler, sondern eine Vereinfachung: volatility wird damit zu einer Eigenschaft des Marktes statt zu einer Formel im Handelsablauf, und verschiedene Märkte können sich verschieden stark bewegen. Der Preis dafür ist, dass große Aufträge den Markt nicht mehr stärker bewegen als kleine — eine plausible erste Übungsaufgabe, das wieder einzubauen, diesmal an der richtigen Stelle.
if __name__ == "__main__":
# In a Jupyter notebook, this blocks the cell until 'q' is pressed.
logic = EconomyEngine()
app = TerminalUI(logic)
app.run()----------------------------------------
MARKET: Food @ 10.00 €
----------------------------------------
[Money: 100.00 € | Stock: Empty]
----------------------------------------
History: []
----------------------------------------
COMMANDS: 'b 1' (buy), 's 5' (sell), 'q' (quit)
>> b 1
[SUCCESS] BUY 1 units @ 10.50
----------------------------------------
MARKET: Food @ 10.70 €
----------------------------------------
[Money: 90.00 € | Stock: Food: 1]
----------------------------------------
History: [('BUY', 1, 10.0)]
----------------------------------------
COMMANDS: 'b 1' (buy), 's 5' (sell), 'q' (quit)
>> b 1
[SUCCESS] BUY 1 units @ 11.20
----------------------------------------
MARKET: Food @ 11.40 €
----------------------------------------
[Money: 79.30 € | Stock: Food: 2]
----------------------------------------
History: [('BUY', 1, 10.0), ('BUY', 1, 10.7)]
----------------------------------------
COMMANDS: 'b 1' (buy), 's 5' (sell), 'q' (quit)
>> s 2
[SUCCESS] SELL 2 units @ 10.90
----------------------------------------
MARKET: Food @ 10.80 €
----------------------------------------
[Money: 102.10 € | Stock: Empty]
----------------------------------------
History: [('BUY', 1, 10.0), ('BUY', 1, 10.7), ('SELL', 2, 11.4)]
----------------------------------------
COMMANDS: 'b 1' (buy), 's 5' (sell), 'q' (quit)
>> q
Exiting...
Was hier welchem Kapitel entspricht¶
| Konstrukt in diesem Notebook | Kapitel |
|---|---|
@dataclass, field(default_factory=...) | Objektorientierung: Dataclasses |
enum.Enum, auto() | Objektorientierung: Enums |
Eigene Exception-Hierarchie, raise | Exceptions |
itertools.cycle für den Markttrend | Generatoren und Iteratorprotokoll |
| Type Hints an allen Signaturen | Type Hints |
__str__ statt Formatierung im Aufrufer | Dunder-Methoden |
| Engine/UI-Trennung | User Interfaces, Testing |
Vor- und Nachbedingungen von execute_trade | Design by Contract |
Wie dieselbe Simulation aussieht, wenn eine KI sie in einem Zug erzeugt statt sie schrittweise zu refaktorisieren, zeigt Generierte Wirtschaftssimulation.