Zum Inhalt springen

cat posts/atlantis-pbem-neu-geschrieben-01-warum-neu-schreiben.md

Atlantis PbeM neu geschrieben (Teil 1): Warum eine Engine aus den 90ern ein neues Fundament bekommt

Legacy-Code ist nicht alter Code, sondern Code, den niemand mehr gern anfasst. Teil 1 erklärt, warum die Engine von Atlantis PbeM mit Claude Code nach Python wandert, warum „treu portieren" vor „neu erfinden" kommt – und wie das Projekt aufgesetzt ist, bevor die erste Zeile Spiellogik entsteht.

Atlantis PbeM ist ein rundenbasiertes Strategiespiel per E-Mail – das PbeM im Namen steht für Play by E-Mail. Es existiert seit 1998; das Copyright im Quelltext nennt Geoff Dunbar und Russell Wallace als ursprüngliche Autoren. Spieler schicken Befehle für ihre Einheiten, die Engine rechnet einmal pro Zug alles durch – Bewegung, Handel, Kämpfe, Magie – und erzeugt für jeden Spieler einen Bericht. Zwischen zwei Zügen vergehen Tage. Das ist kein Echtzeit-Spiel, sondern eher ein Batch-Prozess mit Postausgang.

Um Atlantis PbeM geht es in dieser Serie aber nur am Rande. Das eigentliche Thema ist das Vorgehen: Wie portiert man eine gewachsene Codebasis mit einem KI-Agenten, ohne dass am Ende etwas herauskommt, das nur so aussieht wie das Original? Atlantis PbeM ist dafür groß genug, um wehzutun, aber noch klein genug, um das Projekt in einer Artikelserie nachvollziehen zu können. Vorwissen über das Spiel oder über C++ brauchst Du nicht.

Was hier „Legacy" heißt

Das Problem ist nicht, dass die Engine von Atlantis PbeM in C++ geschrieben ist. Der Fork, von dem wir ausgehen, verlangt sogar einen C++20-Compiler mit vollständiger Ranges-Unterstützung. Das Problem ist, dass mehrere Generationen C++ nebeneinanderliegen. In den 71 Dateien, die der Build des standard-Regelwerks tatsächlich kompiliert, steht std::string 850-mal neben 17 rohen char *. Es gibt 274 new und 243 delete, aber nur acht Stellen, die std::ranges oder std::views benutzen. Und Globals->, der Zeiger auf die globale Konfigurationstabelle, taucht 1.215-mal auf.

Am deutlichsten wird es bei den Datentabellen. In gamedata.h steht ein namenloses Enum:

// reference/gamedata.h
enum {
    I_LEADERS,
    I_VIKING,
    I_BARBARIAN,
    I_PLAINSMAN,
    I_ESKIMO,
    I_NOMAD,

Und in gamedata.cpp ein Vektor, dessen Einträge in exakt derselben Reihenfolge stehen müssen:

// reference/gamedata.cpp
std::vector<ItemType> ItemDefs =
{
    {"leader", "leaders", "LEAD",
     ItemType::NOTRANSPORT,
     NULL, 0, 0, 0, {{-1, 0}, {-1, 0}, {-1, 0}, {-1, 0}},

Nichts im Code verbindet I_LEADERS mit "leader" außer der Position. Wer an der falschen Stelle einen Eintrag einfügt, verschiebt alles dahinter. Das ist für mich der interessante Teil von Legacy-Code: Er muss nicht kaputt sein. Er wird zum Problem, wenn kleine Änderungen Auswirkungen haben können, die im Code kaum sichtbar sind.

Die Beitragsregeln des Forks bringen einen anderen Aspekt auf den Punkt. In seiner CLAUDE.md heißt es: „Match the file you are in, not the repository average." Das ist ein ziemlich ehrlicher Umgang mit einer Codebasis, die über Jahre gewachsen ist. Für den Port bedeutet das vor allem: Es gibt keinen einheitlichen Stil, den man einfach übernehmen könnte.

Der Umfang, gemessen mit cloc im Build-Container: 71 Dateien, 47.949 Codezeilen ohne Leer- und Kommentarzeilen – 40 .cpp-Dateien mit 42.784 Zeilen und 31 Header mit 5.165 Zeilen. Das ist nur das Regelwerk standard. Der Fork kennt acht Regelwerke, die sich dieselbe Engine teilen.

Warum Python – und was es kostet

Die Gründe für Python sind die üblichen: Lesbarkeit, ein Modulsystem statt Header-Dateien, Tests ohne viel Zeremonie und eine Werkzeugkette, die heute ziemlich gut zusammenspielt – uv für Umgebung und Abhängigkeiten, ruff für Lint und Format, pyright für Typen und pytest für Tests.

Die Nachteile verschwinden dadurch nicht:

  • Performance. Python ist deutlich langsamer als optimiertes C++. Für eine Engine, die einmal pro Zug läuft und danach wieder Tage wartet, könnte das praktisch egal sein. Ob wir am Ende über Millisekunden, Sekunden oder mehr reden, wird aber nicht geraten, sondern gemessen, sobald beide Engines dasselbe rechnen.
  • Weniger statische Absicherung durch die Sprache. Ein Teil dessen, was der C++-Compiler prüft, existiert in Python nicht in derselben Form. pyright im Strict-Modus fängt einiges davon ab, ersetzt den Compiler aber nicht.
  • Ein Port liefert zunächst nichts Neues. Monate Arbeit, und am Ende tut das Programm im besten Fall genau das, was es vorher auch getan hat. Der Gewinn kommt später: wenn Änderungen wieder möglich werden, ohne dabei ständig den Rest der Engine zu fürchten.

Treu portieren statt neu erfinden

Die Versuchung ist bekannt: Wenn man schon alles anfasst, kann man es doch gleich richtig machen. Fred Brooks hat dafür den Second-System-Effekt beschrieben. Bei einem KI-Agenten wird die Versuchung nicht kleiner. Er kann sehr schnell sehr viel Code produzieren, und plausibler Code ist noch lange kein Code, der sich wie das Original verhält.

Deshalb gilt für dieses Projekt: Das Original ist das Orakel. Der Python-Code soll denselben Zufallszahlengenerator benutzen und Zufallszahlen in derselben Reihenfolge ziehen. Damit lassen sich die Ergebnisse beider Engines vergleichen: gleicher Seed, gleiche Befehle, gleiche Welt. Byte-Identität der Berichte ist nicht das Ziel – gleiches Spielverhalten schon.

Abweichungen sind erlaubt. Aber jede wird bewusst entschieden und vorher in docs/decisions/ dokumentiert. Und wir portieren zunächst ein Regelwerk vollständig: standard. Die anderen sieben kommen erst danach.

Der Plan: messbare Schritte

Die Roadmap zählt 14 Schritte von 0 bis 13; was danach kommt, bleibt bewusst offen. Erst das Fundament aus diesem Teil, dann das Verständnis des Altcodes und anschließend ein Vergleichs-Harness, das beide Engines gegeneinander laufen lässt. Danach kommen die Bausteine von unten nach oben: Zufallsgenerator, statische Spieldaten, Regelbuch-Generator, Objektmodell, Weltgenerierung, Spielstand, Befehle, Berichte und zum Schluss die Zugphasen in derselben Reihenfolge wie im Original.

Über alle Schritte hinweg verfolgen wir drei Kennzahlen: die Portierungsabdeckung als Anteil der C++-Zeilen, die als portiert markiert sind, die Vergleichsquote als Anteil der Szenarien, die das Harness als gleichwertig meldet, und das Laufzeitverhältnis zwischen Python und C++.

Bei einem Agenten ist diese Messbarkeit besonders nützlich. Falscher Code kann genauso sauber aussehen wie richtiger: Er läuft, hat Tests und besteht den Linter. Ohne ein Orakel sagt das noch wenig darüber aus, ob er dasselbe tut wie das Original.

Bevor der Agent Code schreibt: Fragen

Die Regeln des Projekts, die Roadmap und die Vorgaben für diese Artikelserie entstanden vor der ersten eigentlichen Sitzung mit Claude Code, als Entwürfe in einer separaten Planungsrunde. Wie viele Entscheidungen dort fielen, habe ich nicht mitgezählt. Gezählt habe ich ab der ersten Sitzung.

Der Startauftrag war einfach: Entwürfe lesen, das geplante Vorgehen zusammenfassen und offene Fragen stellen. Im Zweifel fragen, statt Lücken selbst zu füllen. Das Ergebnis waren acht Fragen vor dem ersten Commit, alle in einer Antwort entschieden:

  1. Welcher Lizenztext? (dazu gleich mehr)
  2. Auf welchen Commit wird die Referenz gepinnt?
  3. Welches Docker-Image, welcher Build-Pfad?
  4. Wird das Image in der CI jedes Mal gebaut oder in eine Registry gepusht?
  5. Was muss der C++-Job beweisen?
  6. Wie sieht der Branch-Schutz aus?
  7. Welches Format für Entscheidungsdokumente?
  8. Welcher Commit-Stil?

Jede dieser Fragen hätte ein Agent auch still für sich beantworten können. Bei den meisten wäre vermutlich dasselbe herausgekommen. Bei der ersten nicht.

Der Auftrag sagte: GPL-2.0-or-later, der Lizenztext kommt aus dem Fork. Der Agent sah nach und meldete einen Widerspruch: LICENSE.md im Fork enthält den Text der GPL v3, 675 Zeilen, „Version 3, 29 June 2007". GitHub erkennt das Repository deshalb als GPL-3.0. README und Lizenzhinweise im Quelltext sagen dagegen ausdrücklich „Version 2 oder später". Das Upstream-Repository hat denselben Widerspruch.

Statt sich für eine der beiden Varianten zu entscheiden, hat der Agent angehalten. Für das neue Projekt habe ich schließlich den offiziellen GPL-v2-Lizenztext verwendet und die Lizenzierung als GPL-2.0-or-later in README und pyproject.toml festgehalten. Damit passt die Lizenzdatei wieder zu den Hinweisen im übernommenen Quelltext.

CLAUDE.md: Regeln, die immer im Kontext stehen

Claude Code lädt Projektanweisungen aus CLAUDE.md beim Start einer Sitzung in den Kontext. Unsere Fassung hat einen Abschnitt namens „Non-negotiables":

  • Never commit without an explicit review round. Der Agent zeigt den vollständigen Diff, ich gebe frei. Eine Freigabe deckt genau eine Lieferung: Commit, Push, PR, Merge bei grüner CI.
  • Never push to main. Jede Änderung geht über einen Branch und einen Pull Request. Für ein Ein-Personen-Projekt klingt das nach Zeremonie. Für mich ist es die Stelle, an der ich den Agenten bewusst anhalte.
  • English everywhere. Code, Kommentare, Doku und Commits sind Englisch – unabhängig von der Sprache des Gesprächs. Einzige Ausnahme: diese Artikel.
  • The reference is read-only. Der Agent ändert nichts im Fork.
  • Port faithfully. Abweichungen nur mit Entscheidungsdokument.

„Non-negotiable" ist dabei unsere Bezeichnung, keine technische Eigenschaft von CLAUDE.md. Claude Code behandelt diese Dateien als Kontext und Anweisungen, nicht als hart erzwungene Konfiguration. Wenn eine Aktion unter keinen Umständen passieren darf, muss sie zusätzlich technisch begrenzt werden, etwa über Berechtigungen oder einen PreToolUse-Hook.

Eine weitere Regel hat mit dem Code selbst nichts zu tun: Der Agent spricht mich immer mit Vornamen an. Das ist ein kleiner Kanarienvogel. Fehlt der Name, ist das ein sofort sichtbares Warnsignal, dass die Projektanweisungen möglicherweise nicht geladen wurden oder gerade nicht beachtet werden. Ein Beweis ist es nicht – aber der Test kostet nichts.

Die Datei bleibt bewusst schlank. Ihr Inhalt liegt in jeder Sitzung im Kontext. Alles, was nur gelegentlich gebraucht wird, liegt deshalb unter docs/: Roadmap, Porting-Map und Entscheidungsdokumente. CLAUDE.md enthält hauptsächlich die dauerhaft relevanten Regeln und Verweise dorthin.

Setup

Das Fundament entstand in einem Bootstrap-Commit und drei Pull Requests, jeder mit vollständiger Review-Runde. Der vierte Pull Request ist dieser Artikel.

Bootstrap. Ein leeres Repository hat noch keinen Branch, gegen den ein Pull Request laufen könnte. Deshalb gab es genau eine Ausnahme von „Never push to main": einen ersten Commit mit fünf Dateien – README.md, LICENSE, .gitignore, .editorconfig und .gitattributes –, den ich vor dem Push reviewt habe. Direkt danach kam der Branch-Schutz per gh api: Pull Request erforderlich, keine Force-Pushes, kein Löschen, und die Regeln gelten auch für Administratoren. Also für mich.

Dokumentation. CLAUDE.md, Roadmap, die Vorgaben für diese Serie, eine Vorlage für Entscheidungen und ein leeres Gerüst für die Porting-Map. Nebenbei: Die Entwürfe kamen aus Google Drive mit Windows-Zeilenenden. .gitattributes mit * text=auto eol=lf fängt das ab, damit Git unter Windows und die Linux-Container nicht gegeneinander arbeiten.

Python-Projekt. uv mit src-Layout, das Paket heißt atlantis_ng. ruff mit 120 Zeichen Zeilenlänge und pydocstyle, weil jedes Modul im Docstring die C++-Datei nennen muss, die es portiert. pyright läuft im Strict-Modus. Dazu ein Smoke-Test, der zunächst nur prüft, ob sich das Paket importieren lässt. Die CI führt aus, was in CLAUDE.md unter „Commands" steht:

# .github/workflows/ci.yml
      - name: Lint
        run: uv run ruff check .

      - name: Format
        run: uv run ruff format --check .

      - name: Type check
        run: uv run pyright

      - name: Test
        run: uv run pytest

Der Job braucht 16 Sekunden, davon 3 für pyright und 1 für pytest. Das ist die Grundlast, bevor eine Zeile Spiellogik existiert.

Referenz. Der Fork der Atlantis-PbeM-Engine hängt als Git-Submodule unter reference/, gepinnt auf einen festen Commit. Kompiliert wird ausschließlich in Docker. Auf dem Host gibt es keinen C++-Compiler, und das soll so bleiben. Das Image enthält nur die benötigte Toolchain: Ubuntu 24.04, GCC, CMake und cloc. Der Quelltext wird schreibgeschützt hineingemountet:

# tools/reference/Dockerfile
FROM ubuntu:24.04

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        ca-certificates \
        cloc \
        cmake \
        g++ \
        make \
    && rm -rf /var/lib/apt/lists/*

COPY build.sh /usr/local/bin/build-reference

Dasselbe Image läuft lokal und in der CI, mit demselben docker run. Nur der Layer-Cache liegt woanders. Das Skript im Container baut das Ziel standard, ruft das Binary einmal ohne Argumente auf – es meldet sich mit „Atlantis Engine Version: 5.2.5 (beta)" und dem Namen des Regelwerks, „Wyreth" – und leitet anschließend aus den Abhängigkeitsdateien des Compilers die Liste der tatsächlich verwendeten Quellen ab.

Darüber läuft cloc. So entstehen die 71 Dateien von oben: nicht „alles im Repository", sondern genau das, was der Build für standard tatsächlich einbezieht.

Die Zeiten: Lokal dauert der erste Lauf 30,9 Sekunden für das Image und 36,7 Sekunden für den Build, mit 20 parallelen Jobs. Beim zweiten Lauf kommt das Image in 1,3 Sekunden aus dem Cache, der Build bleibt bei 35,7 Sekunden, weil er absichtlich jedes Mal frisch beginnt. In der CI dauert derselbe Job 3 Minuten 47 Sekunden: 60 Sekunden für das Image ohne Cache und 2 Minuten 21 Sekunden für den Build. Der gehostete Runner meldet nproc = 2.

Das Binary, die Dateiliste und der cloc-Bericht landen als Artefakte am CI-Lauf. Beide Jobs sind Pflicht-Checks für main.

Stolperfallen

  • Ein leeres Repository kennt noch keine Pull Requests. Der erste Commit braucht eine bewusste, einmalige Ausnahme von der eigenen Regel. Schreib sie vorher auf und schließ sie danach wieder.
  • Branch-Schutz bei privaten Repositorys hängt vom GitHub-Plan ab. GitHub Free unterstützt geschützte Branches in öffentlichen Repositorys. Für private Repositorys braucht es mindestens GitHub Pro beziehungsweise einen entsprechenden Team- oder Enterprise-Plan. Pflicht-Checks lassen sich außerdem erst auswählen, wenn der betreffende Status-Check bereits mindestens einmal gelaufen ist.
  • Der Agent findet Widersprüche, wenn man ihm erlaubt anzuhalten. Die Lizenzfrage stand in meinem eigenen Auftrag. Entscheidend war nicht, dass der Agent schlauer geraten hat, sondern dass er gar nicht geraten hat: Zwei Quellen widersprachen sich, also kam eine Frage zurück.
  • setup-uv veröffentlicht seit v8 bewusst keine Major- und Minor-Tags mehr. astral-sh/setup-uv@v10 funktioniert deshalb nicht, obwohl Releases wie v10.1.0 existieren. Der erste CI-Lauf brach nach drei Sekunden ab. Für diese Action muss ein unveränderliches Release oder noch besser dessen Commit-SHA verwendet werden.
  • Eine Regel in CLAUDE.md ist kein Sicherheitsmechanismus. Den Einzeiler für den fehlerhaften setup-uv-Pin hat Claude Code committet und gepusht, ohne mir vorher den Diff zu zeigen – entgegen unserer ersten Non-negotiable. Der Agent meldete den Verstoß selbst, aber erst hinterher. Wenn etwas technisch nicht passieren darf, reicht eine Formulierung in CLAUDE.md nicht; dann braucht es zusätzlich einen Hook oder eine andere technische Sperre.
  • Die Werkzeuge haben eigene Sicherheitsnetze, und die kennen Deine Freigabe nicht unbedingt. Der Desktop-Client von Claude Code blockierte den ersten gh pr merge, weil er einen Merge ohne Review vermutete – obwohl ich im Chat bereits freigegeben hatte. Erst nach einer erneuten Bestätigung ging der Merge durch.
  • Linter laufen auch in Submodule hinein, wenn man sie lässt. ruff und pyright fanden die Python-Hilfsskripte des Forks unter reference/ und meldeten Dutzende Verstöße in Code, der uns nichts angeht. Beide brauchen einen expliziten Ausschluss.
  • Zeilenenden sind auf Windows nie ganz erledigt. Git for Windows setzt core.autocrlf=true häufig systemweit, die Entwürfe kamen mit CRLF und die Container erwarten LF. .gitattributes gewinnt gegen autocrlf, aber nur für Dateien, die Git tatsächlich verwaltet. Prüf es mit git ls-files --eol, bevor Du Dich darauf verlässt.

Der Stand nach Teil 1

Noch keine Zeile Spiellogik. Dafür gibt es jetzt ein Repository mit Regeln für jede Sitzung, einer Roadmap mit messbaren Done-Kriterien, einer grünen Python-CI in 16 Sekunden, einem reproduzierbaren Build der C++-Referenz und der Referenz-Engine als Artefakt am CI-Lauf.

Die Zahlen dieses Teils: 71 Dateien und 47.949 Codezeilen, die zu portieren sind. Acht Fragen vor dem ersten Commit, ein gefundener Lizenzwiderspruch, ein Regelverstoß des Agenten und ein blockierter Merge. Ein Bootstrap-Commit und vier Pull Requests.

In Teil 2 geht es darum, den Altcode von Atlantis PbeM zu verstehen, bevor wir ihn anfassen: eine vollständige Karte aller 71 Dateien – was jede tut, wovon sie abhängt und ob sie portiert, durch die Standardbibliothek ersetzt oder weggelassen wird.

Weiterlesen

Sources: geekblogio/atlantis-pbem-engine und Atlantis-PBEM/Atlantis auf GitHub, GNU GPL v2, GitHub – Branch protection, Claude Code – CLAUDE.md und Memory, setup-uv v8.0.0, Fred Brooks: The Mythical Man-Month.

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!