Rust-Werkstatt
Werkstatt · Baulöwe · Etappe 9 von 10

Der Hai

Der stärkste Gegner kommt an den Tisch. Der Hai spielt vor jedem Zug jede Möglichkeit vierzigmal in die Zukunft und nimmt die, die im Durchschnitt am besten ausgeht. Dabei schummelt er nicht: Was er nicht sehen kann, mischt er neu. Und damit das Fenster beim Nachdenken nicht einfriert, rechnet er im Hintergrund.

2 Schritte · ca. 50 Minuten · iced 0.14

Das Spiel mit Toni dem Touristen, Mia der Maklerin und Hanna dem Hai am Tisch
So sieht der Baulöwe am Ende dieser Etappe aus.
Schritt 1

Tausend mögliche Zukünfte

Der Makler denkt nur einen Zug weit. Was danach kommt, schätzt er mit Faustregeln. Der Hai geht einen ganz anderen Weg: Er spielt die Zukunft einfach aus. Für jeden erlaubten Zug spielt er die Partie vierzigmal ein Stück weiter, jedes Mal mit anderen Zufallszügen aller Spieler, und schaut, wie gut er am Ende im Durchschnitt dasteht. Der Zug mit dem besten Durchschnitt gewinnt. Dieses Verfahren heißt Monte-Carlo-Suche, nach dem Spielkasino in Monaco, weil es auf dem Würfeln beruht. Mit ähnlichen Ideen schlagen Computer heute Menschen im Go.

Warum funktioniert das, obwohl die Zufallszüge ziemlich dumm sind? Weil sich Glück und Pech über viele Durchläufe ausgleichen. Ein Zug, der mir eine sichere Mehrheit bringt, führt in den meisten der vierzig Zukünfte zu mehr Geld, egal was die anderen zufällig tun.

Zuerst bekommt der Hai seinen Platz in der Liste der Spielerarten:

src/spiel.rsin enum Art
    Tourist,
    Makler,    Hai,}

Nun zu ki.rs. Oben kommen zwei Stellschrauben dazu, mit denen du später die Stärke des Hais einstellen kannst:

src/ki.rsganz oben und in fn waehle_zug
use rand::seq::IndexedRandom;

use crate::spiel::{Art, Kette, Phase, Spiel, Zug};use rand::Rng;
use rand::seq::{IndexedRandom, SliceRandom};

use crate::spiel::{Art, Feld, Kette, Phase, Spiel, Zug};

// So oft spielt der Hai jeden möglichen Zug durch
const DURCHLAEUFE: usize = 40;
// So viele Handlungen weit schaut er in jede Zukunft
const WEITSICHT: usize = 60;// Welchen Zug macht der Computer, der gerade dran ist?⋮        Art::Makler => makler(spiel),        Art::Hai => hai(spiel),    }

Und dann der Hai selbst, mit zwei Helfern. Alles kommt vor bewerten, denn am Ende jeder Zukunft bewertet der Hai die Lage genauso wie der Makler:

src/ki.rsvor // Wie gut steht Spieler ich da? …
// Der Hai spielt jeden möglichen Zug viele Male in die Zukunft
// und nimmt den, der im Durchschnitt am besten ausgeht
fn hai(spiel: &Spiel) -> Zug {
    let zuege = spiel.moegliche_zuege();
    if zuege.len() == 1 {
        return zuege[0];
    }
    let ich = spiel.handelnder();
    let mut zufall = rand::rng();

    let mut bester_zug = zuege[0];
    let mut bester_schnitt = i64::MIN;
    for &zug in &zuege {
        let mut summe = 0;
        for _ in 0..DURCHLAEUFE {
            let mut probe = spiel.clone();
            verdecken(&mut probe, ich, &mut zufall);
            probe.ausfuehren(zug);
            summe += ausspielen(probe, ich, &mut zufall);
        }
        let schnitt = summe / DURCHLAEUFE as i64;
        if schnitt > bester_schnitt {
            bester_schnitt = schnitt;
            bester_zug = zug;
        }
    }
    bester_zug
}

// Der Hai schummelt nicht: Fremde Hände und den Stapel kennt er nicht.
// Also verteilt er alle Plättchen, die er nicht sehen kann, zufällig neu.
fn verdecken(spiel: &mut Spiel, ich: usize, zufall: &mut impl Rng) {
    let mut unbekannt: Vec<Feld> = spiel.stapel.clone();
    for (nr, spieler) in spiel.spieler.iter().enumerate() {
        if nr != ich {
            unbekannt.extend(&spieler.hand);
        }
    }
    unbekannt.shuffle(zufall);

    for (nr, spieler) in spiel.spieler.iter_mut().enumerate() {
        if nr != ich {
            let anzahl = spieler.hand.len();
            spieler.hand = unbekannt.split_off(unbekannt.len() - anzahl);
        }
    }
    spiel.stapel = unbekannt;
}

// Eine mögliche Zukunft: Alle spielen zufällig weiter, dann wird bewertet
fn ausspielen(mut spiel: Spiel, ich: usize, zufall: &mut impl Rng) -> i64 {
    spiel.protokoll.clear();
    for _ in 0..WEITSICHT {
        if spiel.phase == Phase::Ende {
            break;
        }
        let zuege = spiel.moegliche_zuege();
        let Some(&zug) = zuege.choose(zufall) else {
            break;
        };
        spiel.ausfuehren(zug);
    }
    bewerten(&spiel, ich)
}
// Wie gut steht Spieler ich da? Sein Vermögen minus das des stärksten Gegners
  • hai hat zwei Schleifen: außen über die möglichen Züge, innen über die Durchläufe. Jeder Durchlauf beginnt mit einer frischen Kopie. Gibt es nur einen einzigen erlaubten Zug, spart sich der Hai das Nachdenken.
  • verdecken ist eine Frage der Fairness. Die Kopie des Spiels enthält die Hände aller Spieler und den ganzen Stapel. Würde der Hai damit rechnen, wüsste er, welche Plättchen seine Gegner haben. Das darf er nicht. Also sammelt er alles, was er nicht sehen kann, mischt es und verteilt es neu. So rechnet er mit einer von vielen möglichen Verteilungen, genau wie ein menschlicher Spieler, der rät. split_off schneidet das Ende einer Liste ab und gibt es als neue Liste zurück.
  • ausspielen lässt alle Spieler zufällig weiterziehen, höchstens sechzig Handlungen weit, und bewertet dann. Bis ganz zum Spielende zu rechnen, würde viel länger dauern und kaum mehr bringen. Das Protokoll leeren wir vorher, weil es in der Zukunft niemand liest und jede Kopie sonst unnötig viel Text mitschleppt.
  • impl Rng heißt: irgendein Zufallsgenerator. Wir erzeugen ihn einmal in hai und reichen ihn weiter, statt ihn tausendfach neu anzulegen.

In der Tafel und am Tisch kommt der Hai dazu, und er ersetzt einen der beiden Makler:

src/main.rsin fn neu und in fn view
                ("Mia", Art::Makler),                ("Max", Art::Makler),                ("Hanna", Art::Hai),        ⋮                Art::Makler => format!("{} · Makler", spieler.name),                Art::Hai => format!("{} · Hai", spieler.name),

Und das Turnier aus Etappe 8 soll jetzt den Hai gegen drei Makler antreten lassen:

src/ki.rsim Test turnier
        let arten = [Art::Makler, Art::Tourist, Art::Tourist, Art::Tourist];        let arten = [Art::Hai, Art::Makler, Art::Makler, Art::Makler];

Der Hai braucht viel Rechenzeit. Starte das Turnier deshalb im Release-Modus, in dem Rust das Programm gründlich optimiert. Der erste Aufruf übersetzt alles noch einmal neu und dauert ein paar Minuten, danach geht es schnell:

cargo test --release turnier -- --ignored --nocapture

Bei uns gewann der Hai rund 70 von 100 Partien gegen drei Makler. Ein Gleichgewicht läge bei 25.

Probelauf Speichern, dann im Terminal:
cargo run
Spiel gegen Toni, Mia und Hanna. Achte darauf, was passiert, wenn Hanna dran ist: Das Fenster stockt. Die Maus reagiert nicht, das Brett hebt sich nicht mehr an, und je nach Rechner hängt alles für einen spürbaren Moment. Der Hai rechnet nämlich mitten in update, und solange update läuft, kann iced nichts anderes tun. Genau das beheben wir im nächsten Schritt.
Schritt 2

Denken im Hintergrund

Eine gute Oberfläche friert nie ein. Wenn etwas lange dauert, läuft es im Hintergrund, und das Fenster bleibt bedienbar. iced hat dafür Tasks: Aufgaben, die nebenher erledigt werden. Ist eine Aufgabe fertig, schickt sie ihr Ergebnis als ganz normalen Brief an update. Damit das geht, darf update ab jetzt einen Task zurückgeben:

src/main.rsganz oben und in fn update
    Subscription, Theme, Vector, alignment, font, time,    Subscription, Task, Theme, Vector, alignment, font, time,⋮    fn update(&mut self, message: Message) {    fn update(&mut self, message: Message) -> Task<Message> {

Die meisten Briefe brauchen keinen Task. Sie laufen wie bisher durch das match, und am Ende steht Task::none(). Nur der Takt des Computers startet jetzt eine Aufgabe. Und die Antwort kommt als neuer Brief KiZug zurück:

src/main.rsin enum Message und fn update, ganz unten
    KiTakt,    KiZug(Zug),⋮            // Der Takt für den Computer            Message::KiTakt => {
                if !self.mensch_am_zug() {
                    let zug = ki::waehle_zug(&self.spiel);
                    self.spiel.ausfuehren(zug);
                }
            }
        }
    }            Message::KiTakt => {
                if !self.mensch_am_zug() && !self.denkt {
                    self.denkt = true;
                    let kopie = self.spiel.clone();
                    let denken = async move { ki::waehle_zug(&kopie) };
                    return Task::perform(denken, Message::KiZug);
                }
            }
            // Die Antwort aus dem Hintergrund ist da
            Message::KiZug(zug) => {
                self.denkt = false;
                if !self.mensch_am_zug() && self.spiel.moegliche_zuege().contains(&zug) {
                    self.spiel.ausfuehren(zug);
                }
            }
        }
        Task::none()
    }
    fn view(&self) -> Element<'_, Message> {

Schauen wir genau hin:

  • Eine eigene Kopie. Die Aufgabe läuft auf einem anderen Faden des Prozessors, während das Fenster weiterarbeitet. Sie darf deshalb nicht auf self.spiel zugreifen, das ja jederzeit gezeichnet werden kann. Sie bekommt eine eigene Kopie, und mit async move nimmt sie diese Kopie ganz in ihren Besitz. Rust prüft beim Übersetzen, dass hier nichts geteilt wird, was nicht geteilt werden darf.
  • Task::perform bekommt die Aufgabe und eine Funktion, die aus dem Ergebnis einen Brief macht. Message::KiZug ist so eine Funktion: Sie nimmt einen Zug und macht daraus eine Message.
  • Nicht doppelt denken. Der Takt tickt weiter, während der Hai rechnet. Das Feld denkt sorgt dafür, dass nicht bei jedem Tick eine neue Aufgabe startet.
  • Misstrauen gegenüber alten Antworten. Bis die Antwort kommt, kann sich einiges getan haben, zum Beispiel hast du inzwischen ein neues Spiel gestartet. Deshalb prüfen wir mit moegliche_zuege().contains(&zug), ob der Zug noch erlaubt ist, bevor wir ihn ausführen.

Fehlt noch das Feld denkt, und die Kopfzeile verrät, wenn jemand grübelt:

src/main.rsstruct Bauloewe, fn neu und in fn view
    nacht: bool,    // Rechnet der Computer gerade im Hintergrund?
    denkt: bool,}⋮            nacht: false,            denkt: false,        }⋮            _ if self.spiel.phase == Phase::Ende => String::from("Schlussabrechnung"),            _ if self.denkt => format!("{} denkt nach …", self.spiel.aktueller().name),
Probelauf Speichern, dann im Terminal:
cargo run
Wenn Hanna dran ist, steht oben „Hanna denkt nach …“, und trotzdem kannst du mit der Maus übers Brett fahren, den Nachtmodus umschalten oder dir die Tafeln ansehen. Das Fenster bleibt flüssig, obwohl im Hintergrund tausende Zukünfte durchgerechnet werden.
Zwischenstand

Der komplette Code nach Etappe 9

Hier ist der vollständige Stand nach Etappe 9. Nimm ihn zum Vergleichen, wenn irgendwo ein Fehler steckt, den du nicht findest, oder als Einstiegspunkt, wenn du erst bei der nächsten Etappe dazustößt.

Cargo.toml, src/main.rs, src/spiel.rs, src/ki.rs anzeigen

Cargo.toml

[package]
name = "bauloewe"
version = "0.1.0"
edition = "2024"

[dependencies]
iced = { version = "0.14", features = ["canvas", "tokio"] }
rand = "0.9"

src/main.rs

mod ki;
mod spiel;

use std::time::Duration;

use iced::mouse;
use iced::widget::canvas::{Frame, Path, Stroke, Text, gradient};
use iced::widget::{Row, button, canvas, column, container, row, space, stack, text};
use iced::{
    Background, Border, Color, Element, Fill, Font, Point, Rectangle, Renderer, Shadow, Size,
    Subscription, Task, Theme, Vector, alignment, font, time,
};
use spiel::{
    Art, Belegung, Entscheidung, Feld, KAEUFE_JE_ZUG, Kette, Phase, REIHEN, SICHER, SPALTEN, Spiel,
    Spieler, Zug,
};

// So groß erscheint ein Feld auf dem Bildschirm
const HALBE_BREITE: f32 = 40.0;
const HALBE_HOEHE: f32 = 20.0;

// Höhen in Pixeln
const PLATTE: f32 = 4.0;
const ANGEHOBEN: f32 = 9.0;
const STOCKWERK: f32 = 13.0;

fn main() -> iced::Result {
    iced::application(Bauloewe::neu, Bauloewe::update, Bauloewe::view)
        .title("Baulöwe")
        .window_size((1280.0, 760.0))
        .antialiasing(true)
        .subscription(Bauloewe::abo)
        .run()
}

struct Bauloewe {
    spiel: Spiel,
    maus_feld: Option<Feld>,
    nacht: bool,
    // Rechnet der Computer gerade im Hintergrund?
    denkt: bool,
}

#[derive(Debug, Clone)]
enum Message {
    MausUeber(Option<Feld>),
    Geklickt(Feld),
    TagNacht,
    Spielzug(Zug),
    KiTakt,
    KiZug(Zug),
    NeuesSpiel,
}

impl Bauloewe {
    fn neu() -> Bauloewe {
        Bauloewe {
            spiel: Spiel::neu(&[
                ("Leo", Art::Mensch),
                ("Toni", Art::Tourist),
                ("Mia", Art::Makler),
                ("Hanna", Art::Hai),
            ]),
            maus_feld: None,
            nacht: false,
            denkt: false,
        }
    }

    // Du bist immer Spieler Nummer 0
    fn ich(&self) -> &Spieler {
        &self.spiel.spieler[0]
    }

    fn mensch_am_zug(&self) -> bool {
        self.spiel.aktueller().art == Art::Mensch
    }

    // Ist der Computer dran, tickt alle 0,6 Sekunden ein Takt
    fn abo(&self) -> Subscription<Message> {
        if self.mensch_am_zug() || self.spiel.phase == Phase::Ende {
            Subscription::none()
        } else {
            time::every(Duration::from_millis(600)).map(|_| Message::KiTakt)
        }
    }

    fn update(&mut self, message: Message) -> Task<Message> {
        match message {
            Message::MausUeber(feld) => self.maus_feld = feld,
            Message::TagNacht => self.nacht = !self.nacht,
            // Züge vom Menschen zählen nur, wenn er dran ist
            Message::Geklickt(feld) if self.mensch_am_zug() => {
                self.spiel.ausfuehren(Zug::Legen(feld))
            }
            Message::Spielzug(zug) if self.mensch_am_zug() => self.spiel.ausfuehren(zug),
            Message::Geklickt(_) | Message::Spielzug(_) => {}
            // Noch eine Runde, Tag oder Nacht bleiben, wie sie sind
            Message::NeuesSpiel => {
                let nacht = self.nacht;
                *self = Bauloewe::neu();
                self.nacht = nacht;
            }
            // Der Takt für den Computer
            Message::KiTakt => {
                if !self.mensch_am_zug() && !self.denkt {
                    self.denkt = true;
                    let kopie = self.spiel.clone();
                    let denken = async move { ki::waehle_zug(&kopie) };
                    return Task::perform(denken, Message::KiZug);
                }
            }
            // Die Antwort aus dem Hintergrund ist da
            Message::KiZug(zug) => {
                self.denkt = false;
                if !self.mensch_am_zug() && self.spiel.moegliche_zuege().contains(&zug) {
                    self.spiel.ausfuehren(zug);
                }
            }
        }
        Task::none()
    }

    fn view(&self) -> Element<'_, Message> {
        let farben = self.farben();

        let titel = text("BAULÖWE")
            .size(26)
            .font(Font {
                weight: font::Weight::Bold,
                ..Font::DEFAULT
            })
            .color(farben.schrift);

        let hinweis = match self.maus_feld {
            _ if self.spiel.phase == Phase::Ende => String::from("Schlussabrechnung"),
            _ if self.denkt => format!("{} denkt nach …", self.spiel.aktueller().name),
            _ if !self.mensch_am_zug() => format!("{} ist am Zug …", self.spiel.aktueller().name),
            Some(feld) => format!("Feld {}", feld.name()),
            None => String::from("Fahr mit der Maus über das Baugrundstück"),
        };
        let status = text(hinweis).size(15).color(farben.schrift);
        let kasse = text(format!("Kasse: {}", geld(self.ich().geld)))
            .size(17)
            .font(Font {
                weight: font::Weight::Bold,
                ..Font::DEFAULT
            })
            .color(farben.schrift);

        let beschriftung = if self.nacht { "Tagmodus" } else { "Nachtmodus" };
        let schalter = button(text(beschriftung).size(14))
            .padding([6, 16])
            .on_press(Message::TagNacht)
            .style(move |_theme: &Theme, _status| button::Style {
                background: Some(Background::Color(farben.akzent)),
                text_color: Color::WHITE,
                border: Border {
                    radius: 16.0.into(),
                    ..Border::default()
                },
                ..button::Style::default()
            });

        let kopfzeile = row![titel, space::horizontal(), status, kasse, schalter]
            .spacing(20)
            .padding([14, 24])
            .align_y(alignment::Vertical::Center);

        let brett = canvas(self).width(Fill).height(Fill);

        // Unten: die Plättchen auf deiner Hand
        let mut hand = row![text("Deine Plättchen:").size(15).color(farben.schrift)]
            .spacing(10)
            .align_y(alignment::Vertical::Center);
        for &feld in &self.ich().hand {
            let darf = self.mensch_am_zug() && self.spiel.darf_legen(feld);
            let plaettchen = button(text(feld.name()).size(16).font(Font::MONOSPACE))
                .padding([8, 14])
                .on_press_maybe(darf.then_some(Message::Geklickt(feld)))
                .style(move |_theme: &Theme, status| {
                    let grund = match status {
                        button::Status::Hovered => farben.rasen_hell,
                        button::Status::Disabled => Color {
                            a: 0.5,
                            ..farben.stein
                        },
                        _ => farben.stein,
                    };
                    button::Style {
                        background: Some(Background::Color(grund)),
                        text_color: farben.schrift,
                        border: Border {
                            radius: 6.0.into(),
                            ..Border::default()
                        },
                        ..button::Style::default()
                    }
                });
            hand = hand.push(plaettchen);
        }

        let stapel = text(format!("Im Stapel: {}", self.spiel.stapel.len()))
            .size(14)
            .color(farben.schrift);

        // In der Kaufphase gibt es einen Knopf, der den Zug beendet
        let mut rechts = row![stapel]
            .spacing(16)
            .align_y(alignment::Vertical::Center);
        let ansage = match self.spiel.phase {
            _ if !self.mensch_am_zug() => None,
            Phase::Kaufen { gekauft } => Some(format!(
                "Du darfst noch {} Aktien kaufen",
                KAEUFE_JE_ZUG - gekauft
            )),
            Phase::Legen if !self.spiel.kann_legen() => {
                Some(String::from("Keins deiner Plättchen passt gerade"))
            }
            _ => None,
        };
        if let Some(ansage) = ansage {
            let rest = text(ansage).size(14).color(farben.schrift);
            let fertig = button(text("Zug beenden").size(14))
                .padding([8, 16])
                .on_press(Message::Spielzug(Zug::ZugBeenden))
                .style(move |_theme: &Theme, _status| button::Style {
                    background: Some(Background::Color(farben.akzent)),
                    text_color: Color::WHITE,
                    border: Border {
                        radius: 16.0.into(),
                        ..Border::default()
                    },
                    ..button::Style::default()
                });
            rechts = rechts.push(rest).push(fertig);
        }
        if self.mensch_am_zug() && self.spiel.darf_beenden() {
            let farbe = kettenfarbe(Kette::Koenigshof);
            rechts = rechts.push(farbknopf("Spiel beenden", farbe, Zug::SpielBeenden));
        }

        let fussleiste = row![hand, space::horizontal(), rechts]
            .padding([16, 24])
            .align_y(alignment::Vertical::Center);

        // Kopf- und Fußleiste liegen wie eine Folie über dem Brett
        // Rechts oben: die Tafel mit allen Ketten
        let mut ketten = column![
            text("Hotelketten")
                .size(15)
                .font(Font {
                    weight: font::Weight::Bold,
                    ..Font::DEFAULT
                })
                .color(farben.schrift)
        ]
        .spacing(5);
        let klein = |inhalt: &str, breite: f32| {
            text(inhalt.to_string())
                .size(11)
                .color(farben.beschriftung)
                .width(breite)
        };
        ketten = ketten.push(
            row![
                klein("", 12.0),
                klein("Kette", 90.0),
                klein("Größe", 70.0),
                klein("Kurs", 70.0),
                klein("Du", 30.0),
                klein("Bank", 30.0),
            ]
            .spacing(8),
        );
        for kette in Kette::ALLE {
            let groesse = self.spiel.groesse(kette);
            let farbe = kettenfarbe(kette);
            let punkt = container(space())
                .width(12)
                .height(12)
                .style(move |_theme: &Theme| container::Style {
                    background: Some(Background::Color(farbe)),
                    border: Border {
                        radius: 6.0.into(),
                        ..Border::default()
                    },
                    ..container::Style::default()
                });
            let (angabe, kurs) = if groesse == 0 {
                (String::from("frei"), String::new())
            } else {
                // Sichere Ketten kann niemand mehr schlucken
                let wort = if groesse >= SICHER {
                    "sicher"
                } else {
                    "Felder"
                };
                (format!("{groesse} {wort}"), geld(self.spiel.preis(kette)))
            };
            let zeile = row![
                punkt,
                text(kette.name()).size(13).color(farben.schrift).width(90),
                text(angabe).size(13).color(farben.schrift).width(70),
                text(kurs).size(13).color(farben.schrift).width(70),
                text(self.ich().aktien[kette.nr()].to_string())
                    .size(13)
                    .color(farben.schrift)
                    .width(30),
                text(self.spiel.bank[kette.nr()].to_string())
                    .size(13)
                    .color(farben.schrift)
                    .width(30),
                button(text("+").size(14))
                    .padding([1, 9])
                    .on_press_maybe(
                        (self.mensch_am_zug() && self.spiel.darf_kaufen(kette))
                            .then_some(Message::Spielzug(Zug::Kaufen(kette)))
                    )
                    .style(move |_theme: &Theme, status| {
                        let grund = match status {
                            button::Status::Disabled => Color {
                                a: 0.25,
                                ..farben.akzent
                            },
                            button::Status::Hovered => aufhellen(farben.akzent, 0.2),
                            _ => farben.akzent,
                        };
                        button::Style {
                            background: Some(Background::Color(grund)),
                            text_color: Color::WHITE,
                            border: Border {
                                radius: 12.0.into(),
                                ..Border::default()
                            },
                            ..button::Style::default()
                        }
                    }),
            ]
            .spacing(8)
            .align_y(alignment::Vertical::Center);
            ketten = ketten.push(zeile);
        }
        let tafel_stil = move |_theme: &Theme| container::Style {
            background: Some(Background::Color(Color {
                a: 0.85,
                ..farben.tafel
            })),
            border: Border {
                radius: 10.0.into(),
                ..Border::default()
            },
            ..container::Style::default()
        };
        let tafel = container(ketten).padding(14).style(tafel_stil);

        // Links oben: wer mitspielt und was zuletzt passiert ist
        let mut tisch = column![
            text("Am Tisch")
                .size(15)
                .font(Font {
                    weight: font::Weight::Bold,
                    ..Font::DEFAULT
                })
                .color(farben.schrift)
        ]
        .spacing(4);
        for (nr, spieler) in self.spiel.spieler.iter().enumerate() {
            let aktien: u32 = spieler.aktien.iter().sum();
            let wer = match spieler.art {
                Art::Mensch => spieler.name.clone(),
                Art::Tourist => format!("{} · Tourist", spieler.name),
                Art::Makler => format!("{} · Makler", spieler.name),
                Art::Hai => format!("{} · Hai", spieler.name),
            };
            let zeile = row![
                text(wer).size(13).color(farben.schrift).width(115),
                text(geld(spieler.geld))
                    .size(13)
                    .color(farben.schrift)
                    .width(80),
                text(format!("Aktien: {aktien}"))
                    .size(13)
                    .color(farben.schrift),
            ]
            .spacing(8);

            // Wer am Zug ist, bekommt einen farbigen Hintergrund
            let am_zug = nr == self.spiel.am_zug;
            let zeile = container(zeile)
                .padding([3, 8])
                .style(move |_theme: &Theme| container::Style {
                    background: am_zug.then_some(Background::Color(Color {
                        a: 0.35,
                        ..farben.akzent
                    })),
                    border: Border {
                        radius: 6.0.into(),
                        ..Border::default()
                    },
                    ..container::Style::default()
                });
            tisch = tisch.push(zeile);
        }
        tisch = tisch.push(text("Zuletzt").size(11).color(farben.beschriftung));
        for eintrag in self.spiel.protokoll.iter().rev().take(5) {
            tisch = tisch.push(text(eintrag.as_str()).size(12).color(farben.schrift));
        }
        let tisch = container(tisch).padding(14).width(330).style(tafel_stil);

        let tafelzeile = row![tisch, space::horizontal(), tafel].padding([0, 24]);

        let folie = column![kopfzeile, tafelzeile, space::vertical(), fussleiste];

        // Wartet eine Gründung, kommt das Auswahlfenster obendrauf
        let mut ebenen = stack![brett, folie];
        if self.mensch_am_zug() {
            match self.spiel.phase {
                Phase::Gruenden(_) => ebenen = ebenen.push(self.gruendungsdialog(farben)),
                Phase::Fusion {
                    tot, ueberlebende, ..
                } => ebenen = ebenen.push(self.fusionsdialog(farben, tot, ueberlebende)),
                _ => {}
            }
        }
        if self.spiel.phase == Phase::Ende {
            ebenen = ebenen.push(self.schlussdialog(farben));
        }
        ebenen.into()
    }

    // Das Fenster zum Gründen einer neuen Kette
    fn gruendungsdialog(&self, farben: Farben) -> Element<'_, Message> {
        let mut knoepfe = row![].spacing(8);
        for kette in self.spiel.freie_ketten() {
            let zug = Zug::Gruenden(kette);
            knoepfe = knoepfe.push(farbknopf(kette.name(), kettenfarbe(kette), zug));
        }
        let frage = "Deine Häuser stehen jetzt nebeneinander. Welche Kette gründest du?";
        dialogfenster(farben, "Neue Hotelkette!", frage.to_string(), knoepfe)
    }

    // Das Fenster für deine Aktien einer Kette, die gerade geschluckt wird
    fn fusionsdialog(
        &self,
        farben: Farben,
        tot: Kette,
        ueberlebende: Kette,
    ) -> Element<'_, Message> {
        let anzahl = self.ich().aktien[tot.nr()];
        let preis = self.spiel.preis(tot);
        let frage = format!(
            "{} schluckt {}. Du hast {anzahl} Aktien von {}, eine ist {} wert. Was machst du damit?",
            ueberlebende.name(),
            tot.name(),
            tot.name(),
            geld(preis)
        );

        let verkaufen = format!("Verkaufen für {}", geld(anzahl * preis));
        let tauschen = format!("2 zu 1 in {} tauschen", ueberlebende.name());
        let knoepfe = row![
            farbknopf(
                &verkaufen,
                kettenfarbe(tot),
                Zug::Aktien(Entscheidung::Verkaufen)
            ),
            farbknopf(
                &tauschen,
                kettenfarbe(ueberlebende),
                Zug::Aktien(Entscheidung::Tauschen)
            ),
            farbknopf(
                "Behalten",
                farben.akzent,
                Zug::Aktien(Entscheidung::Behalten)
            ),
        ]
        .spacing(8);
        dialogfenster(farben, "Fusion!", frage, knoepfe)
    }

    // Die Abrechnung: Wer ist der größte Baulöwe?
    fn schlussdialog(&self, farben: Farben) -> Element<'_, Message> {
        let rangliste = self.spiel.rangliste();
        let mut tabelle = String::new();
        for (platz, spieler) in rangliste.iter().enumerate() {
            let zeile = format!("{}. {}: {}\n", platz + 1, spieler.name, geld(spieler.geld));
            tabelle.push_str(&zeile);
        }
        let titel = if rangliste[0].art == Art::Mensch {
            "Du bist der größte Baulöwe!"
        } else {
            "Das Spiel ist aus!"
        };

        let nochmal = button(text("Neues Spiel").size(14))
            .padding([8, 16])
            .on_press(Message::NeuesSpiel)
            .style(move |_theme: &Theme, _status| button::Style {
                background: Some(Background::Color(farben.akzent)),
                text_color: Color::WHITE,
                border: Border {
                    radius: 16.0.into(),
                    ..Border::default()
                },
                ..button::Style::default()
            });
        dialogfenster(farben, titel, tabelle.trim_end().to_string(), row![nochmal])
    }

    fn farben(&self) -> Farben {
        if self.nacht {
            Farben::nacht()
        } else {
            Farben::tag()
        }
    }
}

impl canvas::Program<Message> for Bauloewe {
    type State = ();

    fn update(
        &self,
        _state: &mut Self::State,
        event: &canvas::Event,
        bounds: Rectangle,
        cursor: mouse::Cursor,
    ) -> Option<canvas::Action<Message>> {
        let iso = Iso::neu(bounds.size());
        let feld = cursor
            .position_in(bounds)
            .and_then(|punkt| iso.feld_unter(punkt));

        match event {
            canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
                feld.map(|feld| canvas::Action::publish(Message::Geklickt(feld)))
            }
            canvas::Event::Mouse(_) if feld != self.maus_feld => {
                Some(canvas::Action::publish(Message::MausUeber(feld)))
            }
            _ => None,
        }
    }

    fn draw(
        &self,
        _state: &Self::State,
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: mouse::Cursor,
    ) -> Vec<canvas::Geometry> {
        let mut frame = Frame::new(renderer, bounds.size());
        let farben = self.farben();
        let iso = Iso::neu(bounds.size());

        self.zeichne_himmel(&mut frame, &farben, bounds.size());
        zeichne_sockel(&mut frame, &iso, &farben);

        // Von hinten nach vorne zeichnen, damit Vorderes Hinteres verdeckt
        let mut felder = Vec::new();
        for reihe in 0..REIHEN {
            for spalte in 0..SPALTEN {
                felder.push(Feld { spalte, reihe });
            }
        }
        felder.sort_by_key(|feld| feld.spalte + feld.reihe);

        for feld in felder {
            self.zeichne_parzelle(&mut frame, &iso, &farben, feld);
        }

        vec![frame.into_geometry()]
    }

    fn mouse_interaction(
        &self,
        _state: &Self::State,
        _bounds: Rectangle,
        _cursor: mouse::Cursor,
    ) -> mouse::Interaction {
        match self.maus_feld {
            Some(feld) if self.mensch_am_zug() && self.spiel.darf_legen(feld) => {
                mouse::Interaction::Pointer
            }
            _ => mouse::Interaction::default(),
        }
    }
}

// Alles, was auf die Leinwand gezeichnet wird
impl Bauloewe {
    fn zeichne_himmel(&self, frame: &mut Frame, farben: &Farben, groesse: Size) {
        let verlauf = gradient::Linear::new(Point::ORIGIN, Point::new(0.0, groesse.height))
            .add_stop(0.0, farben.himmel_oben)
            .add_stop(1.0, farben.himmel_unten);
        frame.fill_rectangle(Point::ORIGIN, groesse, verlauf);

        if self.nacht {
            // 90 Sterne, jede Nacht an denselben Stellen
            for nr in 0..90 {
                let x = (zufall(nr, 1) % 1000) as f32 / 1000.0;
                let y = (zufall(nr, 2) % 1000) as f32 / 1000.0;
                let punkt = Point::new(x * groesse.width, y * groesse.height * 0.55);
                let helligkeit = 0.3 + (zufall(nr, 3) % 70) as f32 / 100.0;
                frame.fill(
                    &Path::circle(punkt, 1.2),
                    Color {
                        a: helligkeit,
                        ..Color::WHITE
                    },
                );
            }
        } else {
            // Die Sonne: ein weicher Schein aus vielen blassen Kreisen
            let sonne = Point::new(groesse.width * 0.84, groesse.height * 0.2);
            for ring in 0..12 {
                let radius = 30.0 + ring as f32 * 14.0;
                frame.fill(
                    &Path::circle(sonne, radius),
                    Color::from_rgba(1.0, 0.97, 0.85, 0.05),
                );
            }
            frame.fill(
                &Path::circle(sonne, 26.0),
                Color::from_rgb8(0xff, 0xf6, 0xd8),
            );
        }
    }

    fn zeichne_parzelle(&self, frame: &mut Frame, iso: &Iso, farben: &Farben, feld: Feld) {
        let s = feld.spalte as f32;
        let r = feld.reihe as f32;
        let im_plan = self.mensch_am_zug() && self.spiel.darf_legen(feld);
        let unter_maus = im_plan && self.maus_feld == Some(feld);
        let hoehe = if unter_maus {
            PLATTE + ANGEHOBEN
        } else {
            PLATTE
        };

        // Der Bordstein ist ein flacher Quader
        let rand = 0.1;
        let bordstein = Quader {
            s0: s + rand,
            r0: r + rand,
            s1: s + 1.0 - rand,
            r1: r + 1.0 - rand,
            z0: 0.0,
            z1: hoehe,
            farbe: farben.stein,
        };
        zeichne_quader(frame, iso, &bordstein);

        // Der Rasen liegt als Fläche oben auf dem Bordstein
        let innen = rand + 0.04;
        let rasen = [
            iso.punkt(s + innen, r + innen, hoehe),
            iso.punkt(s + 1.0 - innen, r + innen, hoehe),
            iso.punkt(s + 1.0 - innen, r + 1.0 - innen, hoehe),
            iso.punkt(s + innen, r + 1.0 - innen, hoehe),
        ];
        let gruen = if unter_maus {
            farben.rasen_hell
        } else {
            farben.rasen
        };
        frame.fill(&vieleck(&rasen), gruen);

        if unter_maus {
            let umriss = vieleck(&bordstein.oberseite(iso));
            frame.stroke(
                &umriss,
                Stroke::default().with_color(farben.akzent).with_width(2.0),
            );
        }

        if im_plan {
            zeichne_faehnchen(frame, iso, farben, feld, hoehe);
        }

        match self.spiel.belegung(feld) {
            Belegung::Rohbau => {
                let plan = Bauplan {
                    stockwerke: 3,
                    fassade: farben.beton,
                    band: None,
                    dach: Dach::Flach,
                };
                self.zeichne_haus(frame, iso, farben, feld, hoehe, &plan);
            }
            Belegung::Hotel(kette) => {
                let groesse = self.spiel.groesse(kette);
                let plan = hotel_bauplan(kette, groesse, farben, feld);
                self.zeichne_haus(frame, iso, farben, feld, hoehe, &plan);
            }
            Belegung::Frei => {
                let schild = Text {
                    content: feld.name(),
                    position: iso.punkt(s + 0.5, r + 0.5, hoehe),
                    color: farben.beschriftung,
                    size: 11.0.into(),
                    font: Font::MONOSPACE,
                    align_x: text::Alignment::Center,
                    align_y: alignment::Vertical::Center,
                    ..Text::default()
                };
                // Als Formen zeichnen, damit Häuser davor die Schrift verdecken
                schild.draw_with(|form, farbe| frame.fill(&form, farbe));
            }
        }
    }

    fn zeichne_haus(
        &self,
        frame: &mut Frame,
        iso: &Iso,
        farben: &Farben,
        feld: Feld,
        boden: f32,
        plan: &Bauplan,
    ) {
        let s = feld.spalte as f32;
        let r = feld.reihe as f32;
        let stockwerke = plan.stockwerke;
        let haus = Quader {
            s0: s + 0.2,
            r0: r + 0.2,
            s1: s + 0.8,
            r1: r + 0.8,
            z0: boden,
            z1: boden + stockwerke as f32 * STOCKWERK,
            farbe: plan.fassade,
        };

        // Nachts fällt warmes Licht aus den Fenstern auf den Rasen
        if self.nacht {
            let schein = [
                iso.punkt(s + 0.14, r + 0.14, boden),
                iso.punkt(s + 0.86, r + 0.14, boden),
                iso.punkt(s + 0.86, r + 0.86, boden),
                iso.punkt(s + 0.14, r + 0.86, boden),
            ];
            frame.fill(
                &vieleck(&schein),
                Color {
                    a: 0.18,
                    ..farben.licht
                },
            );
        }

        // Der Schatten fällt nach rechts auf den Rasen
        let schatten = [
            iso.punkt(haus.s1, haus.r0, boden),
            iso.punkt(haus.s1 + 0.12, haus.r0 + 0.08, boden),
            iso.punkt(haus.s1 + 0.12, haus.r1, boden),
            iso.punkt(haus.s1, haus.r1, boden),
        ];
        frame.fill(&vieleck(&schatten), Color::from_rgba(0.0, 0.0, 0.0, 0.25));

        zeichne_quader(frame, iso, &haus);

        // Fenster: pro Stockwerk zwei auf jeder sichtbaren Seite
        for stock in 0..stockwerke {
            let unten = boden + stock as f32 * STOCKWERK + 4.0;
            let oben = unten + 6.0;
            for nr in 0..2 {
                let a = 0.08 + nr as f32 * 0.27;
                let b = a + 0.17;
                let links = [
                    iso.punkt(haus.s0 + a, haus.r1, oben),
                    iso.punkt(haus.s0 + b, haus.r1, oben),
                    iso.punkt(haus.s0 + b, haus.r1, unten),
                    iso.punkt(haus.s0 + a, haus.r1, unten),
                ];
                let rechts = [
                    iso.punkt(haus.s1, haus.r0 + a, oben),
                    iso.punkt(haus.s1, haus.r0 + b, oben),
                    iso.punkt(haus.s1, haus.r0 + b, unten),
                    iso.punkt(haus.s1, haus.r0 + a, unten),
                ];
                for (seite, ecken) in [(0, links), (1, rechts)] {
                    let fenster_nr = stock * 4 + nr * 2 + seite;
                    let brennt =
                        self.nacht && zufall(feld.spalte * 16 + feld.reihe, fenster_nr) % 3 != 0;
                    let farbe = if brennt {
                        farben.licht
                    } else if seite == 0 {
                        farben.fenster
                    } else {
                        abdunkeln(farben.fenster, 0.8)
                    };
                    frame.fill(&vieleck(&ecken), farbe);
                }
            }
        }

        // Ein Band in der Farbe der Kette ganz oben um das Haus
        if let Some(band) = plan.band {
            let unten = haus.z1 - 3.5;
            let links = [
                iso.punkt(haus.s0, haus.r1, haus.z1),
                iso.punkt(haus.s1, haus.r1, haus.z1),
                iso.punkt(haus.s1, haus.r1, unten),
                iso.punkt(haus.s0, haus.r1, unten),
            ];
            let rechts = [
                iso.punkt(haus.s1, haus.r0, haus.z1),
                iso.punkt(haus.s1, haus.r1, haus.z1),
                iso.punkt(haus.s1, haus.r1, unten),
                iso.punkt(haus.s1, haus.r0, unten),
            ];
            frame.fill(&vieleck(&links), band);
            frame.fill(&vieleck(&rechts), abdunkeln(band, 0.75));
        }

        self.zeichne_dach(frame, iso, &haus, plan);
    }

    // Jede Dachform wird anders gezeichnet
    fn zeichne_dach(&self, frame: &mut Frame, iso: &Iso, haus: &Quader, plan: &Bauplan) {
        let farbe = plan.band.unwrap_or(plan.fassade);
        let mitte_s = (haus.s0 + haus.s1) / 2.0;
        let mitte_r = (haus.r0 + haus.r1) / 2.0;

        match plan.dach {
            Dach::Flach => flachdach(frame, iso, haus),
            Dach::Garten => {
                flachdach(frame, iso, haus);
                let mitte = iso.punkt(mitte_s, mitte_r, haus.z1);
                let busch = Color::from_rgb8(0x5f, 0x9a, 0x44);
                frame.fill(
                    &Path::circle(Point::new(mitte.x - 5.0, mitte.y - 3.0), 4.5),
                    busch,
                );
                frame.fill(
                    &Path::circle(Point::new(mitte.x + 4.0, mitte.y - 1.0), 3.5),
                    aufhellen(busch, 0.2),
                );
            }
            Dach::Zinnen => {
                flachdach(frame, iso, haus);
                let d = 0.12;
                for (s, r) in [
                    (haus.s0, haus.r0),
                    (haus.s1 - d, haus.r0),
                    (haus.s0, haus.r1 - d),
                    (haus.s1 - d, haus.r1 - d),
                ] {
                    let zinne = Quader {
                        s0: s,
                        r0: r,
                        s1: s + d,
                        r1: r + d,
                        z0: haus.z1,
                        z1: haus.z1 + 5.0,
                        farbe: haus.farbe,
                    };
                    zeichne_quader(frame, iso, &zinne);
                }
            }
            Dach::Antenne => {
                flachdach(frame, iso, haus);
                let fuss = iso.punkt(mitte_s, mitte_r, haus.z1);
                let spitze = Point::new(fuss.x, fuss.y - 20.0);
                let mast = Stroke::default()
                    .with_color(abdunkeln(farbe, 0.5))
                    .with_width(1.5);
                frame.stroke(&Path::line(fuss, spitze), mast);
                let lampe = if self.nacht {
                    Color::from_rgb8(0xff, 0x4a, 0x3d)
                } else {
                    abdunkeln(farbe, 0.6)
                };
                frame.fill(&Path::circle(spitze, 2.2), lampe);
            }
            Dach::Pyramide => {
                let spitze = iso.punkt(mitte_s, mitte_r, haus.z1 + 16.0);
                pyramide(frame, iso, haus, spitze, farbe);
            }
            Dach::Stufen => {
                for (stufe, hoehe) in [(0.1, 7.0), (0.2, 13.0)] {
                    let aufsatz = Quader {
                        s0: haus.s0 + stufe,
                        r0: haus.r0 + stufe,
                        s1: haus.s1 - stufe,
                        r1: haus.r1 - stufe,
                        z0: haus.z1,
                        z1: haus.z1 + hoehe,
                        farbe: haus.farbe,
                    };
                    zeichne_quader(frame, iso, &aufsatz);
                }
            }
            Dach::Turmspitze => {
                let sockel = Quader {
                    s0: haus.s0 + 0.15,
                    r0: haus.r0 + 0.15,
                    s1: haus.s1 - 0.15,
                    r1: haus.r1 - 0.15,
                    z0: haus.z1,
                    z1: haus.z1 + 5.0,
                    farbe: haus.farbe,
                };
                zeichne_quader(frame, iso, &sockel);
                let spitze = iso.punkt(mitte_s, mitte_r, sockel.z1 + 26.0);
                pyramide(frame, iso, &sockel, spitze, farbe);
            }
        }
    }
}

// Ein Flachdach mit Rand
fn flachdach(frame: &mut Frame, iso: &Iso, haus: &Quader) {
    let innen = 0.05;
    let dach = [
        iso.punkt(haus.s0 + innen, haus.r0 + innen, haus.z1),
        iso.punkt(haus.s1 - innen, haus.r0 + innen, haus.z1),
        iso.punkt(haus.s1 - innen, haus.r1 - innen, haus.z1),
        iso.punkt(haus.s0 + innen, haus.r1 - innen, haus.z1),
    ];
    frame.fill(&vieleck(&dach), abdunkeln(haus.farbe, 0.85));
}

// Ein spitzes Dach: zwei sichtbare Dreiecke von der Oberkante zur Spitze
fn pyramide(frame: &mut Frame, iso: &Iso, unten: &Quader, spitze: Point, farbe: Color) {
    let links = [
        iso.punkt(unten.s0, unten.r1, unten.z1),
        iso.punkt(unten.s1, unten.r1, unten.z1),
        spitze,
    ];
    let rechts = [
        iso.punkt(unten.s1, unten.r0, unten.z1),
        iso.punkt(unten.s1, unten.r1, unten.z1),
        spitze,
    ];
    frame.fill(&vieleck(&links), abdunkeln(farbe, 0.9));
    frame.fill(&vieleck(&rechts), abdunkeln(farbe, 0.65));
}

fn zeichne_sockel(frame: &mut Frame, iso: &Iso, farben: &Farben) {
    let rand = 0.35;
    let (s0, r0) = (-rand, -rand);
    let (s1, r1) = (SPALTEN as f32 + rand, REIHEN as f32 + rand);

    // Ein weicher Schatten unter dem ganzen Brett
    let schatten = [
        iso.punkt(s0, r0, -34.0),
        iso.punkt(s1, r0, -34.0),
        iso.punkt(s1, r1, -34.0),
        iso.punkt(s0, r1, -34.0),
    ];
    frame.fill(&vieleck(&schatten), Color::from_rgba(0.0, 0.0, 0.0, 0.18));

    let sockel = Quader {
        s0,
        r0,
        s1,
        r1,
        z0: -22.0,
        z1: 0.0,
        farbe: farben.strasse,
    };
    zeichne_quader(frame, iso, &sockel);
}

// Ein farbiger Knopf, der einen Spielzug verschickt
fn farbknopf<'a>(beschriftung: &str, farbe: Color, zug: Zug) -> Element<'a, Message> {
    button(text(beschriftung.to_string()).size(14))
        .padding([8, 12])
        .on_press(Message::Spielzug(zug))
        .style(move |_theme: &Theme, status| {
            let grund = match status {
                button::Status::Hovered => aufhellen(farbe, 0.25),
                _ => farbe,
            };
            button::Style {
                background: Some(Background::Color(grund)),
                text_color: Color::WHITE,
                border: Border {
                    radius: 6.0.into(),
                    ..Border::default()
                },
                ..button::Style::default()
            }
        })
        .into()
}

// Eine Karte mit Titel, Frage und Knöpfen über einem dunklen Schleier
fn dialogfenster<'a>(
    farben: Farben,
    titel: &'a str,
    frage: String,
    knoepfe: Row<'a, Message>,
) -> Element<'a, Message> {
    let titel = text(titel)
        .size(24)
        .font(Font {
            weight: font::Weight::Bold,
            ..Font::DEFAULT
        })
        .color(farben.schrift);
    let frage = text(frage)
        .size(15)
        .color(farben.schrift)
        .align_x(text::Alignment::Center);

    let karte = container(
        column![titel, frage, knoepfe]
            .spacing(14)
            .align_x(alignment::Horizontal::Center),
    )
    .padding(24)
    .max_width(640)
    .style(move |_theme: &Theme| container::Style {
        background: Some(Background::Color(farben.tafel)),
        border: Border {
            radius: 12.0.into(),
            ..Border::default()
        },
        shadow: Shadow {
            color: Color::from_rgba(0.0, 0.0, 0.0, 0.35),
            offset: Vector::new(0.0, 8.0),
            blur_radius: 24.0,
        },
        ..container::Style::default()
    });

    // Ein dunkler Schleier über dem Brett, die Karte in der Mitte
    container(karte)
        .center(Fill)
        .style(|_theme: &Theme| container::Style {
            background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.3))),
            ..container::Style::default()
        })
        .into()
}

// Ein Vermessungsfähnchen: Hier darfst du bauen
fn zeichne_faehnchen(frame: &mut Frame, iso: &Iso, farben: &Farben, feld: Feld, boden: f32) {
    let fuss = iso.punkt(feld.spalte as f32 + 0.3, feld.reihe as f32 + 0.3, boden);
    let spitze = Point::new(fuss.x, fuss.y - 20.0);
    let stange = Stroke::default().with_color(farben.stange).with_width(1.5);
    frame.stroke(&Path::line(fuss, spitze), stange);

    let fahne = [
        spitze,
        Point::new(spitze.x + 11.0, spitze.y + 3.5),
        Point::new(spitze.x, spitze.y + 7.0),
    ];
    frame.fill(&vieleck(&fahne), farben.akzent);
}

// Rechnet zwischen Brett und Bildschirm hin und her
struct Iso {
    ursprung: Point,
}

impl Iso {
    fn neu(groesse: Size) -> Iso {
        // Die Mitte des Bretts soll in der Mitte der Leinwand liegen
        let mitte_s = SPALTEN as f32 / 2.0;
        let mitte_r = REIHEN as f32 / 2.0;
        let x = groesse.width / 2.0 - (mitte_s - mitte_r) * HALBE_BREITE;
        let y = groesse.height / 2.0 - (mitte_s + mitte_r) * HALBE_HOEHE + 10.0;
        Iso {
            ursprung: Point::new(x, y),
        }
    }

    // Hinweg: Brett (Spalte, Reihe, Höhe) -> Bildschirm
    fn punkt(&self, s: f32, r: f32, z: f32) -> Point {
        Point::new(
            self.ursprung.x + (s - r) * HALBE_BREITE,
            self.ursprung.y + (s + r) * HALBE_HOEHE - z,
        )
    }

    // Rückweg: Bildschirm -> Feld auf dem Brett
    fn feld_unter(&self, punkt: Point) -> Option<Feld> {
        let dx = (punkt.x - self.ursprung.x) / HALBE_BREITE;
        let dy = (punkt.y - self.ursprung.y) / HALBE_HOEHE;
        let s = (dy + dx) / 2.0;
        let r = (dy - dx) / 2.0;
        if s < 0.0 || r < 0.0 {
            return None;
        }
        let spalte = s as usize;
        let reihe = r as usize;
        if spalte < SPALTEN && reihe < REIHEN {
            Some(Feld { spalte, reihe })
        } else {
            None
        }
    }
}

// Die Beschreibung eines Hauses: wie hoch, welche Farbe
struct Bauplan {
    stockwerke: usize,
    fassade: Color,
    band: Option<Color>,
    dach: Dach,
}

// Die Dachformen der Ketten
#[derive(Clone, Copy)]
enum Dach {
    Flach,
    Garten,
    Zinnen,
    Antenne,
    Pyramide,
    Stufen,
    Turmspitze,
}

// Wie ein Hotel aussieht: Je größer die Kette, desto höher das Haus
fn hotel_bauplan(kette: Kette, groesse: usize, farben: &Farben, feld: Feld) -> Bauplan {
    let grundhoehe = 3.0 + (groesse as f32).sqrt() * 1.5;
    let schwankung = zufall(feld.index(), 7) % 2;
    Bauplan {
        stockwerke: grundhoehe as usize + schwankung as usize,
        fassade: mischen(farben.beton, kettenfarbe(kette), 0.25),
        band: Some(kettenfarbe(kette)),
        dach: dachform(kette),
    }
}

// Jede Kette hat ihre eigene Dachform
fn dachform(kette: Kette) -> Dach {
    match kette {
        Kette::Zugvogel => Dach::Flach,
        Kette::Ankerplatz => Dach::Garten,
        Kette::Bergfried => Dach::Zinnen,
        Kette::Uferblick => Dach::Antenne,
        Kette::Morgenrot => Dach::Pyramide,
        Kette::Aurora => Dach::Stufen,
        Kette::Koenigshof => Dach::Turmspitze,
    }
}

// Jede Kette hat ihre Erkennungsfarbe
fn kettenfarbe(kette: Kette) -> Color {
    match kette {
        Kette::Zugvogel => Color::from_rgb8(0xf2, 0xb1, 0x34),
        Kette::Ankerplatz => Color::from_rgb8(0x2b, 0xb3, 0xa6),
        Kette::Bergfried => Color::from_rgb8(0xd9, 0x48, 0x3b),
        Kette::Uferblick => Color::from_rgb8(0x3b, 0x7d, 0xd8),
        Kette::Morgenrot => Color::from_rgb8(0xe8, 0x73, 0x8f),
        Kette::Aurora => Color::from_rgb8(0x8e, 0x5b, 0xd6),
        Kette::Koenigshof => Color::from_rgb8(0xc9, 0xa2, 0x27),
    }
}

// Ein Quader: von (s0, r0) bis (s1, r1) auf dem Brett, von z0 bis z1 in der Höhe
struct Quader {
    s0: f32,
    r0: f32,
    s1: f32,
    r1: f32,
    z0: f32,
    z1: f32,
    farbe: Color,
}

impl Quader {
    fn oberseite(&self, iso: &Iso) -> [Point; 4] {
        [
            iso.punkt(self.s0, self.r0, self.z1),
            iso.punkt(self.s1, self.r0, self.z1),
            iso.punkt(self.s1, self.r1, self.z1),
            iso.punkt(self.s0, self.r1, self.z1),
        ]
    }
}

fn zeichne_quader(frame: &mut Frame, iso: &Iso, q: &Quader) {
    let links = [
        iso.punkt(q.s0, q.r1, q.z1),
        iso.punkt(q.s1, q.r1, q.z1),
        iso.punkt(q.s1, q.r1, q.z0),
        iso.punkt(q.s0, q.r1, q.z0),
    ];
    let rechts = [
        iso.punkt(q.s1, q.r0, q.z1),
        iso.punkt(q.s1, q.r1, q.z1),
        iso.punkt(q.s1, q.r1, q.z0),
        iso.punkt(q.s1, q.r0, q.z0),
    ];
    let oben = q.oberseite(iso);

    // Das Licht kommt von links: links mittel, rechts dunkel, oben am hellsten
    frame.fill(&vieleck(&links), abdunkeln(q.farbe, 0.78));
    frame.fill(&vieleck(&rechts), abdunkeln(q.farbe, 0.58));
    frame.fill(&vieleck(&oben), q.farbe);

    // Eine feine Lichtkante an den beiden vorderen Oberkanten
    let kante = Path::new(|p| {
        p.move_to(oben[3]);
        p.line_to(oben[2]);
        p.line_to(oben[1]);
    });
    frame.stroke(
        &kante,
        Stroke::default()
            .with_color(aufhellen(q.farbe, 0.35))
            .with_width(1.0),
    );
}

// Baut aus einer Liste von Ecken eine geschlossene Fläche
fn vieleck(ecken: &[Point]) -> Path {
    Path::new(|p| {
        p.move_to(ecken[0]);
        for ecke in &ecken[1..] {
            p.line_to(*ecke);
        }
        p.close();
    })
}

fn abdunkeln(farbe: Color, faktor: f32) -> Color {
    Color {
        r: farbe.r * faktor,
        g: farbe.g * faktor,
        b: farbe.b * faktor,
        a: farbe.a,
    }
}

fn aufhellen(farbe: Color, anteil: f32) -> Color {
    Color {
        r: farbe.r + (1.0 - farbe.r) * anteil,
        g: farbe.g + (1.0 - farbe.g) * anteil,
        b: farbe.b + (1.0 - farbe.b) * anteil,
        a: farbe.a,
    }
}

// Schreibt Geld wie in Deutschland: 6000 wird zu "6.000 €"
fn geld(betrag: u32) -> String {
    let ziffern = betrag.to_string();
    let mut mit_punkten = String::new();
    for (stelle, ziffer) in ziffern.chars().enumerate() {
        if stelle > 0 && (ziffern.len() - stelle) % 3 == 0 {
            mit_punkten.push('.');
        }
        mit_punkten.push(ziffer);
    }
    format!("{mit_punkten} €")
}

// Mischt zwei Farben: anteil 0.0 ist ganz a, 1.0 ist ganz b
fn mischen(a: Color, b: Color, anteil: f32) -> Color {
    Color {
        r: a.r + (b.r - a.r) * anteil,
        g: a.g + (b.g - a.g) * anteil,
        b: a.b + (b.b - a.b) * anteil,
        a: 1.0,
    }
}

// Würfeln ohne Würfel: gleiche Eingabe, immer das gleiche Ergebnis
fn zufall(a: usize, b: usize) -> u32 {
    let mut h = (a as u32).wrapping_mul(2_654_435_761) ^ (b as u32).wrapping_mul(40_503);
    h ^= h >> 15;
    h = h.wrapping_mul(2_246_822_519);
    h ^= h >> 13;
    h
}

// Alle Farben an einem Ort
#[derive(Clone, Copy)]
struct Farben {
    himmel_oben: Color,
    himmel_unten: Color,
    strasse: Color,
    stein: Color,
    rasen: Color,
    rasen_hell: Color,
    beschriftung: Color,
    akzent: Color,
    schrift: Color,
    beton: Color,
    fenster: Color,
    licht: Color,
    stange: Color,
    tafel: Color,
}

impl Farben {
    fn tag() -> Farben {
        Farben {
            himmel_oben: Color::from_rgb8(0x8e, 0xc5, 0xe8),
            himmel_unten: Color::from_rgb8(0xe9, 0xf2, 0xf7),
            strasse: Color::from_rgb8(0x5a, 0x62, 0x70),
            stein: Color::from_rgb8(0xd9, 0xd4, 0xc7),
            rasen: Color::from_rgb8(0x9c, 0xcb, 0x6b),
            rasen_hell: Color::from_rgb8(0xc4, 0xe6, 0x8f),
            beschriftung: Color::from_rgba8(0x1e, 0x32, 0x14, 0.55),
            akzent: Color::from_rgb8(0xce, 0x5a, 0x1e),
            schrift: Color::from_rgb8(0x1c, 0x2a, 0x3a),
            beton: Color::from_rgb8(0xe8, 0xe2, 0xd6),
            fenster: Color::from_rgb8(0x5b, 0x8d, 0xb8),
            licht: Color::from_rgb8(0xff, 0xd2, 0x7a),
            stange: Color::WHITE,
            tafel: Color::from_rgb8(0xfb, 0xf8, 0xf1),
        }
    }

    fn nacht() -> Farben {
        Farben {
            himmel_oben: Color::from_rgb8(0x0b, 0x10, 0x26),
            himmel_unten: Color::from_rgb8(0x2a, 0x23, 0x50),
            strasse: Color::from_rgb8(0x2a, 0x30, 0x42),
            stein: Color::from_rgb8(0x5c, 0x60, 0x6e),
            rasen: Color::from_rgb8(0x2e, 0x4a, 0x36),
            rasen_hell: Color::from_rgb8(0x4d, 0x7a, 0x4f),
            beschriftung: Color::from_rgba8(0xdc, 0xe6, 0xd2, 0.45),
            akzent: Color::from_rgb8(0xe0, 0x7a, 0x2e),
            schrift: Color::from_rgb8(0xe8, 0xec, 0xf5),
            beton: Color::from_rgb8(0x6d, 0x6a, 0x78),
            fenster: Color::from_rgb8(0x27, 0x32, 0x4a),
            licht: Color::from_rgb8(0xff, 0xd2, 0x7a),
            stange: Color::from_rgb8(0xcf, 0xd3, 0xdc),
            tafel: Color::from_rgb8(0x1e, 0x22, 0x33),
        }
    }
}

src/spiel.rs

use std::cmp::Reverse;

use rand::seq::SliceRandom;

// Das Brett: 12 Spalten (1 bis 12) und 9 Reihen (A bis I)
pub const SPALTEN: usize = 12;
pub const REIHEN: usize = 9;

// So viele Plättchen hat man auf der Hand
pub const HANDGROESSE: usize = 6;

// Geld und Aktien
pub const STARTGELD: u32 = 6000;
pub const AKTIEN_JE_KETTE: u32 = 25;
pub const KAEUFE_JE_ZUG: u32 = 3;

// Ab dieser Größe kann eine Kette nicht mehr geschluckt werden
pub const SICHER: usize = 11;

// Ist eine Kette so groß, darf man das Spiel beenden
pub const RIESIG: usize = 41;

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Feld {
    pub spalte: usize,
    pub reihe: usize,
}

impl Feld {
    // Die Nummer des Feldes in einer Liste mit 108 Einträgen
    pub fn index(self) -> usize {
        self.reihe * SPALTEN + self.spalte
    }

    // Die bis zu vier Nachbarn: links, rechts, oben, unten
    pub fn nachbarn(self) -> Vec<Feld> {
        let mut liste = Vec::new();
        if self.spalte > 0 {
            liste.push(Feld {
                spalte: self.spalte - 1,
                reihe: self.reihe,
            });
        }
        if self.spalte + 1 < SPALTEN {
            liste.push(Feld {
                spalte: self.spalte + 1,
                reihe: self.reihe,
            });
        }
        if self.reihe > 0 {
            liste.push(Feld {
                spalte: self.spalte,
                reihe: self.reihe - 1,
            });
        }
        if self.reihe + 1 < REIHEN {
            liste.push(Feld {
                spalte: self.spalte,
                reihe: self.reihe + 1,
            });
        }
        liste
    }

    // Der Name wie auf dem Spielplan: "1A" bis "12I"
    pub fn name(self) -> String {
        let buchstabe = (b'A' + self.reihe as u8) as char;
        format!("{}{}", self.spalte + 1, buchstabe)
    }
}

// Die sieben Hotelketten
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Kette {
    Zugvogel,
    Ankerplatz,
    Bergfried,
    Uferblick,
    Morgenrot,
    Aurora,
    Koenigshof,
}

impl Kette {
    pub const ALLE: [Kette; 7] = [
        Kette::Zugvogel,
        Kette::Ankerplatz,
        Kette::Bergfried,
        Kette::Uferblick,
        Kette::Morgenrot,
        Kette::Aurora,
        Kette::Koenigshof,
    ];

    // Die Nummer der Kette: 0 bis 6
    pub fn nr(self) -> usize {
        self as usize
    }

    // Preisstufe: 0 günstig, 1 mittel, 2 teuer
    pub fn stufe(self) -> u32 {
        match self {
            Kette::Zugvogel | Kette::Ankerplatz => 0,
            Kette::Bergfried | Kette::Uferblick | Kette::Morgenrot => 1,
            Kette::Aurora | Kette::Koenigshof => 2,
        }
    }

    pub fn name(self) -> &'static str {
        match self {
            Kette::Zugvogel => "Zugvogel",
            Kette::Ankerplatz => "Ankerplatz",
            Kette::Bergfried => "Bergfried",
            Kette::Uferblick => "Uferblick",
            Kette::Morgenrot => "Morgenrot",
            Kette::Aurora => "Aurora",
            Kette::Koenigshof => "Königshof",
        }
    }
}

// Was auf einem Feld steht
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Belegung {
    Frei,
    Rohbau,
    Hotel(Kette),
}

// Woran das Spiel gerade ist
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Phase {
    // Warten auf ein Plättchen
    Legen,
    // Hier wartet eine neue Kette auf ihren Namen
    Gruenden(Feld),
    // Aktien kaufen, bis zu drei Stück
    Kaufen {
        gekauft: u32,
    },
    // Eine Kette wird geschluckt, die Aktionäre entscheiden reihum
    Fusion {
        feld: Feld,
        tot: Kette,
        ueberlebende: Kette,
    },
    // Das Spiel ist aus
    Ende,
}

// Was ein Aktionär mit Aktien einer geschluckten Kette macht
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Entscheidung {
    Verkaufen,
    Tauschen,
    Behalten,
}

// Alles, was ein Spieler tun kann
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Zug {
    Legen(Feld),
    Gruenden(Kette),
    Kaufen(Kette),
    Aktien(Entscheidung),
    ZugBeenden,
    SpielBeenden,
}

// Wer da spielt: ein Mensch oder der Computer
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Art {
    Mensch,
    Tourist,
    Makler,
    Hai,
}

// Was einem Spieler gehört
#[derive(Clone)]
pub struct Spieler {
    pub name: String,
    pub art: Art,
    pub geld: u32,
    pub aktien: [u32; 7],
    pub hand: Vec<Feld>,
}

// Alles, was die Spielregeln wissen müssen
#[derive(Clone)]
pub struct Spiel {
    pub brett: Vec<Belegung>,
    pub stapel: Vec<Feld>,
    pub phase: Phase,
    pub spieler: Vec<Spieler>,
    // Wer gerade dran ist: die Nummer in der Liste der Spieler
    pub am_zug: usize,
    // Was bisher passiert ist, ein Eintrag pro Handlung
    pub protokoll: Vec<String>,
    // Bei einer Fusion: Diese Ketten werden noch geschluckt
    pub offene_fusionen: Vec<Kette>,
    // Diese Aktionäre müssen noch entscheiden, der vorderste zuerst
    pub entscheider: Vec<usize>,
    // Die Aktien, die noch niemandem gehören
    pub bank: [u32; 7],
}

impl Spiel {
    pub fn neu(teilnehmer: &[(&str, Art)]) -> Spiel {
        // Für jedes Feld ein Plättchen, dann gut mischen
        let mut stapel = Vec::new();
        for reihe in 0..REIHEN {
            for spalte in 0..SPALTEN {
                stapel.push(Feld { spalte, reihe });
            }
        }
        stapel.shuffle(&mut rand::rng());

        let spieler = teilnehmer
            .iter()
            .map(|&(name, art)| Spieler {
                name: name.to_string(),
                art,
                geld: STARTGELD,
                aktien: [0; 7],
                hand: Vec::new(),
            })
            .collect();

        let mut spiel = Spiel {
            brett: vec![Belegung::Frei; SPALTEN * REIHEN],
            stapel,
            phase: Phase::Legen,
            spieler,
            am_zug: 0,
            protokoll: Vec::new(),
            offene_fusionen: Vec::new(),
            entscheider: Vec::new(),
            bank: [AKTIEN_JE_KETTE; 7],
        };

        // Reihum zieht jeder seine Hand, dann fängt der Erste an
        for nr in 0..spiel.spieler.len() {
            spiel.am_zug = nr;
            spiel.nachziehen();
        }
        spiel.am_zug = 0;
        spiel
    }

    // Wer gerade handeln muss: Bei einer Fusion entscheiden die Aktionäre reihum
    pub fn handelnder(&self) -> usize {
        match self.phase {
            Phase::Fusion { .. } => self.entscheider[0],
            _ => self.am_zug,
        }
    }

    // Der Spieler, der gerade handeln muss
    pub fn aktueller(&self) -> &Spieler {
        &self.spieler[self.handelnder()]
    }

    // Dasselbe zum Verändern
    fn dran(&mut self) -> &mut Spieler {
        let nr = self.handelnder();
        &mut self.spieler[nr]
    }

    // Schreibt ins Protokoll, was der Spieler am Zug getan hat
    fn notiere(&mut self, was: String) {
        let eintrag = format!("{} {was}", self.aktueller().name);
        self.protokoll.push(eintrag);
    }

    // Was steht auf diesem Feld?
    pub fn belegung(&self, feld: Feld) -> Belegung {
        self.brett[feld.index()]
    }

    // Füllt die Hand des Spielers am Zug wieder auf, solange der Stapel reicht
    fn nachziehen(&mut self) {
        let nr = self.am_zug;

        // Tote Plättchen kommen aus dem Spiel
        let tote: Vec<Feld> = self.spieler[nr]
            .hand
            .iter()
            .copied()
            .filter(|&feld| self.ist_tot(feld))
            .collect();
        for feld in tote {
            self.spieler[nr].hand.retain(|&h| h != feld);
            let eintrag = format!("{} wirft {} ab", self.spieler[nr].name, feld.name());
            self.protokoll.push(eintrag);
        }

        while self.spieler[nr].hand.len() < HANDGROESSE {
            match self.stapel.pop() {
                Some(feld) => self.spieler[nr].hand.push(feld),
                None => break,
            }
        }
        self.spieler[nr]
            .hand
            .sort_by_key(|feld| (feld.spalte, feld.reihe));
    }

    // Darf der Spieler am Zug dieses Plättchen jetzt legen?
    pub fn darf_legen(&self, feld: Feld) -> bool {
        self.phase == Phase::Legen && self.aktueller().hand.contains(&feld) && self.passt(feld)
    }

    // Passt dieses Plättchen gerade aufs Brett, egal wer es auf der Hand hat?
    pub fn passt(&self, feld: Feld) -> bool {
        if self.ist_tot(feld) {
            return false;
        }
        // Eine neue Kette gründen geht nur, wenn noch eine frei ist
        let gruendet = self.ketten_neben(feld).is_empty() && self.rohbau_neben(feld);
        !(gruendet && self.freie_ketten().is_empty())
    }

    // Ein totes Plättchen würde zwei sichere Ketten verbinden. Es passt nie mehr.
    pub fn ist_tot(&self, feld: Feld) -> bool {
        let sichere = self
            .ketten_neben(feld)
            .iter()
            .filter(|&&kette| self.groesse(kette) >= SICHER)
            .count();
        sichere >= 2
    }

    // Hat der Spieler am Zug überhaupt ein Plättchen, das passt?
    pub fn kann_legen(&self) -> bool {
        self.aktueller()
            .hand
            .iter()
            .any(|&feld| self.darf_legen(feld))
    }

    // Legt ein Plättchen aus der Hand. Gibt false zurück, wenn das nicht geht.
    pub fn legen(&mut self, feld: Feld) -> bool {
        if !self.darf_legen(feld) {
            return false;
        }
        let ketten = self.ketten_neben(feld);
        let rohbau_daneben = self.rohbau_neben(feld);

        self.dran().hand.retain(|&h| h != feld);
        self.notiere(format!("legt {}", feld.name()));
        self.brett[feld.index()] = Belegung::Rohbau;

        if ketten.len() > 1 {
            self.fusion(feld, ketten);
        } else if let Some(&kette) = ketten.first() {
            self.ausbreiten(feld, kette);
            self.nach_dem_legen();
        } else if rohbau_daneben {
            self.phase = Phase::Gruenden(feld);
        } else {
            self.nach_dem_legen();
        }
        true
    }

    // Das Plättchen verbindet mehrere Ketten: Die größte schluckt die anderen
    fn fusion(&mut self, feld: Feld, mut ketten: Vec<Kette>) {
        // Größte zuerst. Bei Gleichstand gewinnt die teurere Kette.
        ketten.sort_by_key(|&kette| Reverse((self.groesse(kette), kette.stufe())));
        let ueberlebende = ketten[0];
        self.offene_fusionen = ketten[1..].to_vec();
        self.naechste_fusion(feld, ueberlebende);
    }

    // Nimmt sich die nächste Kette vor, die geschluckt wird
    fn naechste_fusion(&mut self, feld: Feld, ueberlebende: Kette) {
        if self.offene_fusionen.is_empty() {
            self.ausbreiten(feld, ueberlebende);
            self.nach_dem_legen();
            return;
        }
        let tot = self.offene_fusionen.remove(0);
        let eintrag = format!(
            "{}: {} schluckt {}",
            self.spieler[self.am_zug].name,
            ueberlebende.name(),
            tot.name()
        );
        self.protokoll.push(eintrag);
        self.praemien_zahlen(tot);

        // Reihum, beim Spieler am Zug beginnend, entscheidet jeder, der Aktien hat
        let anzahl = self.spieler.len();
        let entscheider: Vec<usize> = (0..anzahl)
            .map(|schritt| (self.am_zug + schritt) % anzahl)
            .filter(|&nr| self.spieler[nr].aktien[tot.nr()] > 0)
            .collect();
        self.entscheider = entscheider;

        if self.entscheider.is_empty() {
            self.umfaerben(tot, ueberlebende);
            self.naechste_fusion(feld, ueberlebende);
        } else {
            self.phase = Phase::Fusion {
                feld,
                tot,
                ueberlebende,
            };
        }
    }

    // Ein Aktionär entscheidet, was mit seinen Aktien der geschluckten Kette passiert
    fn entscheiden(&mut self, wahl: Entscheidung) {
        let Phase::Fusion {
            feld,
            tot,
            ueberlebende,
        } = self.phase
        else {
            return;
        };
        let preis = self.preis(tot);
        let anzahl = self.aktueller().aktien[tot.nr()];

        match wahl {
            Entscheidung::Verkaufen => {
                let spieler = self.dran();
                spieler.geld += anzahl * preis;
                spieler.aktien[tot.nr()] = 0;
                self.bank[tot.nr()] += anzahl;
                self.notiere(format!("verkauft {anzahl} {}", tot.name()));
            }
            Entscheidung::Tauschen => {
                // Zwei alte gegen eine neue, solange die Bank welche hat
                let paare = (anzahl / 2).min(self.bank[ueberlebende.nr()]);
                let spieler = self.dran();
                spieler.aktien[tot.nr()] -= paare * 2;
                spieler.aktien[ueberlebende.nr()] += paare;
                self.bank[tot.nr()] += paare * 2;
                self.bank[ueberlebende.nr()] -= paare;
                self.notiere(format!("tauscht in {paare} {}", ueberlebende.name()));
            }
            Entscheidung::Behalten => {
                self.notiere(format!("behält {anzahl} {}", tot.name()));
            }
        }

        self.entscheider.remove(0);
        if self.entscheider.is_empty() {
            self.umfaerben(tot, ueberlebende);
            self.naechste_fusion(feld, ueberlebende);
        }
    }

    // Die beiden größten Aktionäre der geschluckten Kette bekommen eine Prämie
    fn praemien_zahlen(&mut self, kette: Kette) {
        let preis = self.preis(kette);
        let bestand: Vec<u32> = self.spieler.iter().map(|s| s.aktien[kette.nr()]).collect();

        let meiste = bestand.iter().copied().max().unwrap_or(0);
        if meiste == 0 {
            return;
        }
        let zweitmeiste = bestand
            .iter()
            .copied()
            .filter(|&b| b < meiste)
            .max()
            .unwrap_or(0);

        let erste: Vec<usize> = (0..bestand.len())
            .filter(|&nr| bestand[nr] == meiste)
            .collect();
        let zweite: Vec<usize> = (0..bestand.len())
            .filter(|&nr| zweitmeiste > 0 && bestand[nr] == zweitmeiste)
            .collect();

        let gross = preis * 10;
        let klein = preis * 5;
        if erste.len() > 1 || zweite.is_empty() {
            // Gleichstand an der Spitze oder nur einer hält Aktien: Alles wird geteilt
            self.auszahlen(&erste, gross + klein);
        } else {
            self.auszahlen(&erste, gross);
            self.auszahlen(&zweite, klein);
        }
    }

    // Teilt eine Summe gerecht auf, aufgerundet auf volle Hundert
    fn auszahlen(&mut self, empfaenger: &[usize], summe: u32) {
        let anteil = (summe / empfaenger.len() as u32).div_ceil(100) * 100;
        for &nr in empfaenger {
            self.spieler[nr].geld += anteil;
            let eintrag = format!("{} erhält {anteil} € Prämie", self.spieler[nr].name);
            self.protokoll.push(eintrag);
        }
    }

    // Alle Hotels einer Kette gehören ab jetzt einer anderen
    fn umfaerben(&mut self, alt: Kette, neu: Kette) {
        for belegung in self.brett.iter_mut() {
            if *belegung == Belegung::Hotel(alt) {
                *belegung = Belegung::Hotel(neu);
            }
        }
    }

    // Gründet an der wartenden Stelle eine neue Kette
    pub fn gruenden(&mut self, kette: Kette) {
        let Phase::Gruenden(feld) = self.phase else {
            return;
        };
        if !self.freie_ketten().contains(&kette) {
            return;
        }
        self.ausbreiten(feld, kette);

        // Wer gründet, bekommt eine Aktie geschenkt
        if self.bank[kette.nr()] > 0 {
            self.bank[kette.nr()] -= 1;
            self.dran().aktien[kette.nr()] += 1;
        }
        self.notiere(format!("gründet {}", kette.name()));
        self.nach_dem_legen();
    }

    // Darf man jetzt eine Aktie dieser Kette kaufen?
    pub fn darf_kaufen(&self, kette: Kette) -> bool {
        let Phase::Kaufen { gekauft } = self.phase else {
            return false;
        };
        let preis = self.preis(kette);
        gekauft < KAEUFE_JE_ZUG
            && preis > 0
            && self.bank[kette.nr()] > 0
            && self.aktueller().geld >= preis
    }

    // Kauft eine Aktie. Nach dem dritten Kauf ist der Zug vorbei.
    pub fn kaufen(&mut self, kette: Kette) {
        if !self.darf_kaufen(kette) {
            return;
        }
        let Phase::Kaufen { gekauft } = self.phase else {
            return;
        };
        let preis = self.preis(kette);
        let spieler = self.dran();
        spieler.geld -= preis;
        spieler.aktien[kette.nr()] += 1;
        self.bank[kette.nr()] -= 1;
        self.notiere(format!("kauft {}", kette.name()));

        if gekauft + 1 == KAEUFE_JE_ZUG {
            self.naechster_zug();
        } else {
            self.phase = Phase::Kaufen {
                gekauft: gekauft + 1,
            };
        }
    }

    // Beendet die Kaufphase und damit den Zug
    pub fn zug_beenden(&mut self) {
        match self.phase {
            Phase::Kaufen { .. } => self.naechster_zug(),
            // Passt kein Plättchen, geht es ohne Legen weiter
            Phase::Legen if !self.kann_legen() => {
                self.notiere(String::from("kann nichts legen"));
                self.nach_dem_legen();
            }
            _ => {}
        }
    }

    // Alle Züge, die der Spieler am Zug gerade machen darf
    pub fn moegliche_zuege(&self) -> Vec<Zug> {
        let mut zuege = Vec::new();
        match self.phase {
            Phase::Legen => {
                for &feld in &self.aktueller().hand {
                    if self.darf_legen(feld) {
                        zuege.push(Zug::Legen(feld));
                    }
                }
                if zuege.is_empty() {
                    zuege.push(Zug::ZugBeenden);
                }
                if self.darf_beenden() {
                    zuege.push(Zug::SpielBeenden);
                }
            }
            Phase::Gruenden(_) => {
                for kette in self.freie_ketten() {
                    zuege.push(Zug::Gruenden(kette));
                }
            }
            Phase::Kaufen { .. } => {
                for kette in self.aktive_ketten() {
                    if self.darf_kaufen(kette) {
                        zuege.push(Zug::Kaufen(kette));
                    }
                }
                zuege.push(Zug::ZugBeenden);
            }
            Phase::Fusion { .. } => {
                zuege.push(Zug::Aktien(Entscheidung::Verkaufen));
                zuege.push(Zug::Aktien(Entscheidung::Tauschen));
                zuege.push(Zug::Aktien(Entscheidung::Behalten));
            }
            Phase::Ende => {}
        }
        zuege
    }

    // Führt einen Zug aus, egal ob er vom Menschen oder vom Computer kommt
    pub fn ausfuehren(&mut self, zug: Zug) {
        match zug {
            Zug::Legen(feld) => {
                self.legen(feld);
            }
            Zug::Gruenden(kette) => self.gruenden(kette),
            Zug::Kaufen(kette) => self.kaufen(kette),
            Zug::Aktien(wahl) => self.entscheiden(wahl),
            Zug::ZugBeenden => self.zug_beenden(),
            Zug::SpielBeenden => {
                if self.darf_beenden() {
                    self.notiere(String::from("beendet das Spiel"));
                    self.schluss();
                }
            }
        }
    }

    // Gibt es schon Ketten, darf man Aktien kaufen. Sonst ist der Zug vorbei.
    fn nach_dem_legen(&mut self) {
        if self.aktive_ketten().is_empty() {
            self.naechster_zug();
        } else {
            self.phase = Phase::Kaufen { gekauft: 0 };
        }
    }

    // Nachziehen, und der Nächste ist dran
    fn naechster_zug(&mut self) {
        self.nachziehen();
        self.am_zug = (self.am_zug + 1) % self.spieler.len();
        self.phase = Phase::Legen;

        // Stapel leer und nichts passt mehr: Dann ist von selbst Schluss
        if self.stapel.is_empty() && self.niemand_kann_legen() {
            self.schluss();
        }
    }

    // Kein Spieler hat noch ein Plättchen, das aufs Brett passt
    fn niemand_kann_legen(&self) -> bool {
        self.spieler
            .iter()
            .all(|spieler| spieler.hand.iter().all(|&feld| !self.passt(feld)))
    }

    // Beenden darf man, wenn eine Kette riesig ist oder alle Ketten sicher sind
    pub fn darf_beenden(&self) -> bool {
        let aktive = self.aktive_ketten();
        let riesig = aktive.iter().any(|&kette| self.groesse(kette) >= RIESIG);
        let alle_sicher = aktive.iter().all(|&kette| self.groesse(kette) >= SICHER);
        self.phase == Phase::Legen && !aktive.is_empty() && (riesig || alle_sicher)
    }

    // Alle Spieler, der reichste zuerst
    pub fn rangliste(&self) -> Vec<&Spieler> {
        let mut liste: Vec<&Spieler> = self.spieler.iter().collect();
        liste.sort_by_key(|spieler| Reverse(spieler.geld));
        liste
    }

    // Schluss: letzte Prämien, dann kauft die Bank alle Aktien zum Kurs zurück
    fn schluss(&mut self) {
        for kette in self.aktive_ketten() {
            self.praemien_zahlen(kette);
            let preis = self.preis(kette);
            for spieler in self.spieler.iter_mut() {
                spieler.geld += spieler.aktien[kette.nr()] * preis;
                spieler.aktien[kette.nr()] = 0;
            }
        }
        self.protokoll.push(String::from("Das Spiel ist aus!"));
        self.phase = Phase::Ende;
    }

    // Wie viele Felder gehören zu dieser Kette?
    pub fn groesse(&self, kette: Kette) -> usize {
        self.brett
            .iter()
            .filter(|&&belegung| belegung == Belegung::Hotel(kette))
            .count()
    }

    // Was eine Aktie dieser Kette gerade kostet
    pub fn preis(&self, kette: Kette) -> u32 {
        let grundpreis = match self.groesse(kette) {
            0 => return 0,
            groesse @ 1..=5 => groesse as u32 * 100,
            6..=10 => 600,
            11..=20 => 700,
            21..=30 => 800,
            31..=40 => 900,
            _ => 1000,
        };
        grundpreis + kette.stufe() * 100
    }

    // Alle Ketten, die schon auf dem Brett stehen
    pub fn aktive_ketten(&self) -> Vec<Kette> {
        Kette::ALLE
            .into_iter()
            .filter(|&kette| self.groesse(kette) > 0)
            .collect()
    }

    // Alle Ketten, die noch nicht auf dem Brett stehen
    pub fn freie_ketten(&self) -> Vec<Kette> {
        Kette::ALLE
            .into_iter()
            .filter(|&kette| self.groesse(kette) == 0)
            .collect()
    }

    // Steht neben diesem Feld ein Rohbau?
    fn rohbau_neben(&self, feld: Feld) -> bool {
        feld.nachbarn()
            .iter()
            .any(|&nachbar| self.belegung(nachbar) == Belegung::Rohbau)
    }

    // Welche Ketten grenzen an dieses Feld? Jede nur einmal.
    fn ketten_neben(&self, feld: Feld) -> Vec<Kette> {
        let mut ketten = Vec::new();
        for nachbar in feld.nachbarn() {
            if let Belegung::Hotel(kette) = self.belegung(nachbar) {
                if !ketten.contains(&kette) {
                    ketten.push(kette);
                }
            }
        }
        ketten
    }

    // Färbt ein Feld und alle angrenzenden Rohbauten in der Farbe der Kette
    fn ausbreiten(&mut self, start: Feld, kette: Kette) {
        let mut offen = vec![start];
        while let Some(feld) = offen.pop() {
            self.brett[feld.index()] = Belegung::Hotel(kette);
            for nachbar in feld.nachbarn() {
                if self.belegung(nachbar) == Belegung::Rohbau {
                    offen.push(nachbar);
                }
            }
        }
    }
}

src/ki.rs

use rand::Rng;
use rand::seq::{IndexedRandom, SliceRandom};

use crate::spiel::{Art, Feld, Kette, Phase, Spiel, Zug};

// So oft spielt der Hai jeden möglichen Zug durch
const DURCHLAEUFE: usize = 40;
// So viele Handlungen weit schaut er in jede Zukunft
const WEITSICHT: usize = 60;

// Welchen Zug macht der Computer, der gerade dran ist?
pub fn waehle_zug(spiel: &Spiel) -> Zug {
    match spiel.aktueller().art {
        Art::Mensch | Art::Tourist => tourist(spiel),
        Art::Makler => makler(spiel),
        Art::Hai => hai(spiel),
    }
}

// Der Tourist denkt nicht nach: Er nimmt irgendeinen erlaubten Zug
fn tourist(spiel: &Spiel) -> Zug {
    let zuege = spiel.moegliche_zuege();
    zuege
        .choose(&mut rand::rng())
        .copied()
        .unwrap_or(Zug::ZugBeenden)
}

// Der Makler probiert jeden erlaubten Zug auf einer Kopie des Spiels aus
// und nimmt den, nach dem er am besten dasteht
fn makler(spiel: &Spiel) -> Zug {
    let ich = spiel.handelnder();
    let mut bester_zug = Zug::ZugBeenden;
    let mut beste_bewertung = i64::MIN;

    for zug in spiel.moegliche_zuege() {
        let mut probe = spiel.clone();
        probe.ausfuehren(zug);
        let bewertung = vorausschauen(&probe, ich, 2);
        if bewertung > beste_bewertung {
            beste_bewertung = bewertung;
            bester_zug = zug;
        }
    }
    bester_zug
}

// Muss ich nach diesem Zug gleich noch einmal entscheiden (Gründung, Fusion),
// schaut der Makler weiter und rechnet mit seiner besten Antwort
fn vorausschauen(spiel: &Spiel, ich: usize, tiefe: u32) -> i64 {
    let gleich_nochmal = matches!(spiel.phase, Phase::Gruenden(_) | Phase::Fusion { .. })
        && spiel.handelnder() == ich;
    if !gleich_nochmal || tiefe == 0 {
        return bewerten(spiel, ich);
    }
    spiel
        .moegliche_zuege()
        .into_iter()
        .map(|zug| {
            let mut probe = spiel.clone();
            probe.ausfuehren(zug);
            vorausschauen(&probe, ich, tiefe - 1)
        })
        .max()
        .unwrap_or_else(|| bewerten(spiel, ich))
}

// Der Hai spielt jeden möglichen Zug viele Male in die Zukunft
// und nimmt den, der im Durchschnitt am besten ausgeht
fn hai(spiel: &Spiel) -> Zug {
    let zuege = spiel.moegliche_zuege();
    if zuege.len() == 1 {
        return zuege[0];
    }
    let ich = spiel.handelnder();
    let mut zufall = rand::rng();

    let mut bester_zug = zuege[0];
    let mut bester_schnitt = i64::MIN;
    for &zug in &zuege {
        let mut summe = 0;
        for _ in 0..DURCHLAEUFE {
            let mut probe = spiel.clone();
            verdecken(&mut probe, ich, &mut zufall);
            probe.ausfuehren(zug);
            summe += ausspielen(probe, ich, &mut zufall);
        }
        let schnitt = summe / DURCHLAEUFE as i64;
        if schnitt > bester_schnitt {
            bester_schnitt = schnitt;
            bester_zug = zug;
        }
    }
    bester_zug
}

// Der Hai schummelt nicht: Fremde Hände und den Stapel kennt er nicht.
// Also verteilt er alle Plättchen, die er nicht sehen kann, zufällig neu.
fn verdecken(spiel: &mut Spiel, ich: usize, zufall: &mut impl Rng) {
    let mut unbekannt: Vec<Feld> = spiel.stapel.clone();
    for (nr, spieler) in spiel.spieler.iter().enumerate() {
        if nr != ich {
            unbekannt.extend(&spieler.hand);
        }
    }
    unbekannt.shuffle(zufall);

    for (nr, spieler) in spiel.spieler.iter_mut().enumerate() {
        if nr != ich {
            let anzahl = spieler.hand.len();
            spieler.hand = unbekannt.split_off(unbekannt.len() - anzahl);
        }
    }
    spiel.stapel = unbekannt;
}

// Eine mögliche Zukunft: Alle spielen zufällig weiter, dann wird bewertet
fn ausspielen(mut spiel: Spiel, ich: usize, zufall: &mut impl Rng) -> i64 {
    spiel.protokoll.clear();
    for _ in 0..WEITSICHT {
        if spiel.phase == Phase::Ende {
            break;
        }
        let zuege = spiel.moegliche_zuege();
        let Some(&zug) = zuege.choose(zufall) else {
            break;
        };
        spiel.ausfuehren(zug);
    }
    bewerten(&spiel, ich)
}

// Wie gut steht Spieler ich da? Sein Vermögen minus das des stärksten Gegners
fn bewerten(spiel: &Spiel, ich: usize) -> i64 {
    let mein = vermoegen(spiel, ich);
    let bester_gegner = (0..spiel.spieler.len())
        .filter(|&nr| nr != ich)
        .map(|nr| vermoegen(spiel, nr))
        .max()
        .unwrap_or(0);

    // Ganz ohne Bargeld kann man nicht mehr kaufen: Ein Polster ist Gold wert
    let geld = spiel.spieler[ich].geld as i64;
    let polster = if spiel.phase != Phase::Ende && geld < 800 {
        800 - geld
    } else {
        0
    };
    mein - bester_gegner - polster
}

// Geld, Aktien zum Kurs und ein Teil der Prämien, die man erwarten darf
fn vermoegen(spiel: &Spiel, nr: usize) -> i64 {
    let spieler = &spiel.spieler[nr];
    let mut summe = spieler.geld as i64;

    for kette in Kette::ALLE {
        // Aktien einer Kette, die gerade geschluckt wird, zählen nicht mehr
        if let Phase::Fusion { tot, .. } = spiel.phase {
            if tot == kette {
                continue;
            }
        }
        let preis = spiel.preis(kette) as i64;
        let meine = spieler.aktien[kette.nr()];
        summe += meine as i64 * preis;

        // Wer hält mehr Aktien als ich? Davon hängt meine Prämie ab.
        let mehr = spiel
            .spieler
            .iter()
            .filter(|anderer| anderer.aktien[kette.nr()] > meine)
            .count();
        if meine > 0 && mehr == 0 {
            summe += preis * 5;
        } else if meine > 0 && mehr == 1 {
            summe += preis * 2;
        }
    }
    summe
}

#[cfg(test)]
mod tests {
    use super::*;

    // Ein kleines Turnier: Wer gewinnt wie oft?
    // Starten mit: cargo test turnier -- --ignored --nocapture
    #[test]
    #[ignore]
    fn turnier() {
        let arten = [Art::Hai, Art::Makler, Art::Makler, Art::Makler];
        let mut siege = [0; 4];
        for _ in 0..100 {
            let teilnehmer: Vec<(&str, Art)> = arten.iter().map(|&art| ("KI", art)).collect();
            let mut spiel = Spiel::neu(&teilnehmer);
            while spiel.phase != Phase::Ende {
                let zug = waehle_zug(&spiel);
                spiel.ausfuehren(zug);
            }
            let sieger = (0..4).max_by_key(|&nr| spiel.spieler[nr].geld).unwrap();
            siege[sieger] += 1;
        }
        println!("Siege: {siege:?}");
    }
}
ZusammenfassungDie Monte-Carlo-Suche spielt jeden Zug viele Male mit Zufall in die Zukunft und wählt den besten Durchschnitt. Mit verdecken rechnet der Hai nur mit dem, was er wissen kann. Lange Rechnungen gehören nicht in update, sondern in einen Task: Er bekommt eine eigene Kopie der Daten, läuft im Hintergrund und meldet sich mit einem Brief zurück. Antworten aus dem Hintergrund prüft man, bevor man sie verwendet. Und für Messungen startet man Rust im Release-Modus.
Übungen

Zum Ausprobieren

  1. Hai oder Goldfisch? Setz DURCHLAEUFE auf 5 und dann auf 150. Wie ändert sich die Siegquote im Turnier, und wie lange denkt Hanna?
  2. Kluge Zukunft: In ausspielen ziehen alle zufällig. Lass die Gegner dort stattdessen wie Makler spielen. Wird der Hai besser? Und langsamer?
  3. Drei Haie: Setz dich an einen Tisch mit drei Haien. Schaffst du es, zu gewinnen?
Lösungen anzeigen

1. Mit 5 Durchläufen ist der Hai deutlich schwächer, weil ihn Glück und Pech in so wenigen Zukünften täuschen. Mit 150 wird er etwas stärker, denkt aber fast viermal so lange. Irgendwann lohnt sich mehr Rechenzeit kaum noch. Das ist typisch für Monte-Carlo-Verfahren.

2. In ausspielen die Zeile mit choose ersetzen durch let zug = makler(&spiel); (das let … else fällt weg). Die Zukünfte werden realistischer, aber jeder Schritt kostet jetzt selbst ein paar Kopien. Rechne mit einem vielfach langsameren Hai. Oft lohnt sich eine Mischung, etwa die Hälfte der Züge zufällig.

3. In Bauloewe::neu drei Einträge mit Art::Hai. Viel Erfolg!

Etappe 9 geschafft 🦈

Du hast drei Gegner gebaut, vom Zufall über Faustregeln bis zur Monte-Carlo-Suche, und ein Turnier, das ihre Stärke misst. In der letzten Etappe wird der Baulöwe rund: Du wählst deine Gegner vor dem Spiel, Hotels wachsen sichtbar in die Höhe, und nachts leuchten auf den Dächern die Namen der Ketten.

Hat dir das Mitbauen Spaß gemacht? Ein Kaffee hilft, dass die nächste Etappe ein Stück schneller fertig wird.

☕ Kaffee spendieren