Zum Inhalt springen

cat posts/atlantis-pbem-neu-geschrieben-03-das-vergleichs-harness.md

Atlantis PbeM neu geschrieben (Teil 3): Das Vergleichs-Harness, oder: Wem traust Du, wenn Du "gleich" sagst?

Bevor die erste Zeile portiert wird, braucht der Port einen Richter: ein Harness, das die C++-Referenz auf festen Szenarien spielt und Verhalten statt Formatierung vergleicht. Teil 3 zeigt, wie es entsteht und warum es sich erst gegen die Referenz selbst beweisen muss.

In Teil 2 entstand die Karte: 71 C++-Dateien, jede mit Klassifikation und Zielmodul. Die Portierungsabdeckung stand bei null Prozent, und sie bleibt auch in diesem Teil dort. Schritt 2 der Roadmap portiert nichts. Er baut das Werkzeug, das später entscheiden soll, ob der Python-Port dasselbe tut wie die C++-Referenz: das Vergleichs-Harness.

Die Regel aus Teil 1 lautet „Verhalten folgt der C++-Referenz“. Das klingt eindeutig, beurteilt aber noch keinen Pull Request. Dafür brauchen wir ein Programm, das definierte Szenarien mit der Referenz durchspielt, ihre Ausgaben einsammelt und später denselben Ablauf mit dem Python-Port wiederholt.

Das Done-Kriterium hat deshalb zwei Hälften: Das Harness muss die Referenz mit sich selbst vergleichen und jedes Szenario als gleichwertig erkennen. Gleichzeitig muss eine absichtlich veränderte Ausgabe zuverlässig auffallen. Ein Vergleich, der immer nur „gleich“ sagt, ist kein Vergleich, sondern ein grüner Balken.

Was das Binary vorgibt

Bevor ein Harness eine Engine antreiben kann, muss klar sein, wie diese Engine benutzt wird. Der Agent las dafür die Schnittstellendokumentation des Forks unter docs/interface/ und fasste das Wesentliche im Arbeitsjournal zusammen.

Atlantis PbeM ist kein Server. Die Engine ist eher ein Batch-Prozess: Sie liest Dateien mit festen Namen aus ihrem aktuellen Arbeitsverzeichnis, berechnet genau einen Zug und schreibt wieder Dateien mit festen Namen dorthin.

  • standard new erzeugt eine Welt und schreibt game.out und players.out. Mit ATLANTIS_SEED lässt sich der Seed für die Weltgenerierung festlegen. Das standard-Regelwerk fragt bei new nichts über stdin ab.
  • standard run liest game.in, players.in und vorhandene orders.<n> und schreibt unter anderem game.out, players.out, report.<n>, report.<n>.json, template.<n> und gegebenenfalls times.<zufall>. Das Umbenennen von *.out nach *.in für den nächsten Zug ist Sache des Aufrufers.
  • report.<n>.json ist die strukturierte Grundlage des Berichts; der Textbericht wird daraus gerendert. Fraktion 1, die NPC-Stadtwache, erhält bei aktivem GM-Report einen weltweiten Spielleiterbericht. Schon bei unserer kleinen Welt ist dessen JSON rund 6,3 MB groß.
  • game.out ist ein positionsbasiertes Textformat ohne Feldnamen. Die Dokumentation des Forks sagt dazu ausdrücklich: „Treat it as opaque“. Eine kleine Welt kommt auf rund 150.000 Zeilen, viele davon bestehen nur aus kurzen Zahlen.
  • Der Suffix von times.<zufall> wird aus dem Zufallsgenerator gezogen. Wer diese Dateien nach Namen vergleicht, vergleicht also auch den zufällig gewählten Dateinamen.

Gerade der letzte Punkt zeigt, dass „beobachtbar“ nicht automatisch „für unseren Vergleich relevant“ bedeutet. Der zufällige Suffix gehört zum Verhalten der Engine, sagt aber nichts über den Inhalt des Welt-Ereignisses aus. Für das Harness ist der Name deshalb Rauschen, der Dateiinhalt nicht.

Vier Fragen vor der ersten Zeile

Wie in den Teilen davor begann der Schritt nicht mit Code, sondern mit einem Vorschlag und offenen Fragen. Drei davon haben das Harness wesentlich geprägt.

Woher kommen die Szenarien? Der Fork bringt bereits eine Snapshot-Suite mit: 14 aufgezeichnete Züge einer standard-Partie, insgesamt rund 42 MB, deren Ausgaben bytegenau verglichen werden. Der Agent empfahl trotzdem eigene, kleine Szenarien. Ich wollte wissen, warum:

Zu 1: Erklär mir warum Du eigene Szenarien besser findest. Ich bin dem nicht
abgeneigt, aber ich würde gerne wissen, was Dich an den Snapshots stört.

Die Antwort hatte drei gute Gründe.

Erstens kam die Welt in turns/turn_0/game.in 2023 mit einem Upstream-Merge in den Fork. Der ursprüngliche Worldgen-Seed ist nicht dokumentiert. Ab Schritt 7, wenn wir die Weltgenerierung portieren, brauchen wir aber Szenarien, die bei new mit einem bekannten Seed beginnen.

Zweitens brauchen wir von den 42 MB nur einen kleinen Teil als Eingabe. Im Wesentlichen sind das game.in, players.in und die Befehlsdateien der einzelnen Züge. Der große Rest besteht aus erwarteten Ausgaben, die unser Harness ohnehin frisch erzeugen soll.

Drittens wurden diese Befehle gespielt, nicht als Testfälle entworfen. Sie sind wertvoll, aber niemand kann ohne weitere Analyse sagen, welche Zugphasen und Grenzfälle sie tatsächlich abdecken.

Für die Snapshots spricht trotzdem einiges: 14 echte Züge enthalten mehr reales Verhalten, als wir in Schritt 2 von Hand nachbauen werden. Die Entscheidung war deshalb kein Entweder-oder. Zuerst entstehen kleine, gezielte Szenarien. Danach prüfen wir, ob die gepinnte Referenz die vorhandenen Snapshots weiterhin bytegenau reproduziert. Wenn ja, kommt die Partie als größeres Replay-Szenario dazu.

Wann kommt der Zustandsdump? Die Roadmap verlangt schon in Schritt 2 ein Format, in dem beide Engines ihren Zustand strukturiert ausgeben können. Die C++-Referenz besitzt so einen Dump aber nicht. Er müsste aus game.out erzeugt werden, und dafür brauchen wir im Kern den Spielstand-Leser, der erst später ohnehin entsteht.

Den jetzt schon zu implementieren hieße, dieselbe Arbeit zweimal zu machen. Also wird in diesem Schritt nur das Format definiert. Der Konverter kommt als erster Pull Request von Schritt 7.

Was kostet die CI? Dazu später. Die Antwort war deutlich teurer als erwartet.

Der Runner

Ein Szenario ist ein Verzeichnis. Seine Metadaten stehen in scenario.toml, die zugabhängigen Eingaben unter turn_<n>/:

# tools/compare/scenarios/first-faction/scenario.toml
description = "A player faction joins in turn 1 and gives orders in turns 2 and 3."
seed = 20260822
turns = 3

Neben orders.<fraktion> darf ein Zugverzeichnis eine Datei players.append enthalten. Damit bildet das Szenario nach, wie ein Spielleiter in Atlantis PbeM eine neue Fraktion anlegt: Ein Block mit Faction: new wird an die Spielerdatei angehängt, die Engine vergibt beim nächsten Zug die Nummer.

Der Runner übernimmt die Verkettung der übrigen Dateien selbst:

# tools/compare/runner.py
def _prepare_inputs(scenario: Scenario, turn: int, previous: Path, workdir: Path) -> None:
    """Last turn's outputs become this turn's inputs, plus the scenario's orders and additions."""
    shutil.copyfile(previous / "game.out", workdir / "game.in")
    players = (previous / "players.out").read_bytes()
    inputs = scenario.inputs(turn)
    if (inputs / PLAYERS_APPEND).is_file():
        players += (inputs / PLAYERS_APPEND).read_bytes()
    (workdir / "players.in").write_bytes(players)
    if inputs.is_dir():
        for orders in sorted(inputs.glob("orders.*")):
            shutil.copyfile(orders, workdir / orders.name)

Jeder Schritt bekommt ein eigenes Arbeitsverzeichnis unter build/compare/<szenario>/<label>/turn_<n>/. Darin bleiben Eingaben und Ausgaben zusammen liegen. Das dupliziert ein paar Megabyte, macht dafür aber jederzeit sichtbar, welcher Zustand für welchen Zug galt.

Die Referenz läuft pro Schritt in einem Docker-Container, mit demselben Toolchain-Image, in dem sie auch gebaut wird. Binary und Arbeitsverzeichnis werden hineingemountet, --entrypoint zeigt auf standard. Unter Linux läuft der Container mit der UID des Aufrufers, damit die erzeugten Dateien anschließend nicht root gehören. Unter Windows gibt es os.getuid nicht; dort übernimmt Docker Desktop die Abbildung.

Hinter dem Runner steht ein kleines Protokoll namens Engine mit zwei Methoden: new(workdir, seed) und run(workdir). Im Moment implementiert nur ReferenceEngine dieses Protokoll. Wenn später die Python-Engine dazukommt, ändert sich für den Runner nichts.

Für den Anfang reichen drei Szenarien, alle mit Seed 20260822:

worldgen führt nur new aus. idle lässt anschließend drei Züge verstreichen, ohne dass ein Spieler Befehle gibt; Stadtwachen, Monster und Wirtschaft laufen trotzdem. first-faction legt in Zug 1 die Fraktion „Test“ an. In Zug 2 verlässt ihre Einheit 404 den Nexus Richtung Nordosten. In Zug 3 gründet sie eine Arbeitereinheit, beansprucht Silber, kauft zehn Seeelfen und arbeitet, während der Anführer Magie studiert.

Solche Befehle lassen sich teilweise erst schreiben, nachdem der vorige Zug gelaufen ist. Einheitennummern, Ausgänge und Marktangebote stehen im Bericht. Der erste Versuch für Zug 3 endete entsprechend mit:

STUDY: Not enough funds to study force

Ein Magier startet ohne Silber. Mit claim 100 davor meldete der Bericht anschließend:

Studies force at a cost of 100 silver

Der Seed 20260822 ist ebenfalls nicht zufällig gewählt. Er stammt aus dem Worldgen-Fixture der Snapshot-Suite. Damit bekam der Runner einen ersten unabhängigen Test: Wenn er die Referenz korrekt startet, müssen game.out, players.out und die Konsolenausgabe von worldgen/turn_0 bytegenau mit dem vorhandenen Fixture übereinstimmen.

$ cmp worldgen/reference/turn_0/game.out ../../reference/snapshot-tests/worldgen/standard/output/game.out \
    && echo WORLDGEN BYTE-IDENTICAL
WORLDGEN BYTE-IDENTICAL

Damit werden auf einmal Seed, Arbeitsverzeichnis, Mounts und der Ablauf von new geprüft. Der Test wird ohne Referenz-Binary übersprungen und braucht mit Binary rund 0,7 Sekunden.

Was zählt als Verhalten

Die Normalisierung ist die gefährlichste Stelle im Harness. Jede Regel, die einen Unterschied ausblendet, kann theoretisch auch einen echten Fehler verstecken. Deshalb gibt es nur wenige Regeln, und sie stehen explizit in docs/comparison.md.

  • Bei Textartefakten werden CRLF zu LF und Leerzeichen am Zeilenende entfernt.
  • Die menschenlesbaren Versionszeilen der Engine werden ausgeblendet: Atlantis Engine Version: …, Saved Game Engine Version: …, Saved Rule-Set Version: … und Wyreth, Version: ….
  • report.<n>.json wird als JSON geparst und mit sortierten Schlüsseln wieder serialisiert. Die drei Versionsstrings unter engine werden entfernt.

Alles andere zählt. Auch die numerischen Versionsfelder in game.out und players.out, denn diese Dateien schreibt die Engine selbst, und der Port soll kompatible Spielstände erzeugen. Einheitennummern, Regionskoordinaten und die Reihenfolge der Regionen im Bericht sind Verhalten.

times.* ist die Ausnahme beim Dateinamen: Pro Zug werden die Dateien als sortierte Liste ihrer Inhalte verglichen. Der zufällige Suffix spielt keine Rolle, Anzahl und Inhalt der Artikel dagegen schon.

Für gewöhnliche Textdateien sieht die Normalisierung so aus:

# tools/compare/normalize.py
def normalize_text(data: bytes) -> bytes:
    """LF line endings, no trailing whitespace, no version lines, exactly one final newline."""
    lines = data.replace(b"\r\n", b"\n").split(b"\n")
    if lines and lines[-1] == b"":
        lines.pop()
    kept = [line.rstrip() for line in lines if not any(pattern.match(line) for pattern in VERSION_LINES)]
    return b"".join(line + b"\n" for line in kept)

Hier bleiben wir absichtlich auf Byte-Ebene. Das Harness dekodiert gewöhnliche Textartefakte nicht erst in irgendein semantisches Modell. JSON ist die bewusste Ausnahme: Dort sollen Schlüsselreihenfolge, Einrückung und Zeilenenden gerade keine Bedeutung haben, Werte und Struktur aber sehr wohl.

Der Selbsttest und die Mutation

Noch bevor der eigentliche Comparator fertig war, prüfte der Agent eine Voraussetzung: Ist die Referenz überhaupt deterministisch?

Dafür lief dasselbe Szenario zweimal in frischen Verzeichnissen. Ein diff -r über die beiden Bäume fand keinen Unterschied. Das ist bei dieser Engine nicht selbstverständlich. Die Dokumentation des Forks beschreibt einen älteren Fehler, bei dem eine liegen gebliebene times.*-Datei den Zufallsstrom verschieben konnte. Die gepinnte Referenz enthält den Fix, und der Runner beginnt ohnehin jeden Schritt in einem frischen Verzeichnis.

Danach kam selfcheck: jedes Szenario zweimal ausführen und die beiden Ergebnisse mit dem neuen Comparator vergleichen.

first-faction: reference vs reference-again: equivalent (4 turns, 27 artefacts)
idle: reference vs reference-again: equivalent (4 turns, 18 artefacts)
worldgen: reference vs reference-again: equivalent (1 turns, 3 artefacts)

Für alle drei Szenarien zusammen dauerte das lokal 43,6 Sekunden. Fast die gesamte Zeit steckt in Docker: Ein Zug kostet durch das Harness drei bis vier Sekunden, innerhalb des Containers gemessen 2,0 bis 2,3. Der Rest sind Container-Start und Bind-Mounts unter Docker Desktop.

Damit war aber erst die einfache Hälfte des Done-Kriteriums erfüllt. Jetzt musste das Harness beweisen, dass es einen Fehler auch tatsächlich findet.

Der Agent veränderte im zweiten Lauf zwei Ausgaben: eine Zeile in game.out und einen Wert in report.1.json.

Der erste Versuch ging schief. Er suchte nach "population": und behandelte den Wert wie eine Zahl. Im Bericht ist population an dieser Stelle aber ein Objekt. Die Mutation erzeugte ungültiges JSON, und der Comparator brach mit JSONDecodeError ab.

Der Mutationsfehler war banal. Interessanter war, dass das Harness daran starb. Eine unlesbare Ausgabe ist schließlich selbst ein Unterschied und sollte als solcher erscheinen.

Seitdem gibt es neben changed, missing und extra auch not readable, inklusive Pfad und Parser-Meldung:

idle: reference vs reference-again: 2 differences (4 turns, 18 artefacts)
  turn 2 game.out: changed
    --- left
    +++ right
    @@ -198,7 +198,7 @@
     3
     COMB 90000
     OBSE 825000
    -TACT 150000
    +TACT 1500001
     NO_SKILL
     0
     -1
  turn 2 report.1.json: not readable
    D:\...\build\compare\idle\reference-again\turn_2\report.1.json: Expecting ',' delimiter: line 7610 column 23 (char 250415)

Beim zweiten Versuch wurde ein echter JSON-Wert verändert. Der Comparator fand "amount": 2346 gegen 2347 und zeigte die umgebenden Zeilen.

Zum Gegenversuch wurde dieselbe Datei nur neu formatiert: andere Einrückung, CRLF statt LF, aber identische JSON-Daten. Diesmal meldete das Harness keinen Unterschied. Genau das soll die Normalisierung leisten: Formatierung verschwindet, ein geänderter Wert nicht.

Dann fiel die Laufzeit des Diffs auf.

game.out hat ungefähr 150.000 Zeilen, viele davon kurze und häufig wiederholte Zahlen. difflib.unified_diff baut intern auf SequenceMatcher auf. Dessen Laufzeit hängt stark davon ab, wie die Sequenzen aufgebaut sind; häufige Elemente werden zudem von der standardmäßig aktiven autojunk-Heuristik als „popular“ behandelt. Für unseren positionsbasierten Zahlenteppich war ein vollständiger menschenlesbarer Diff schlicht unnötig teuer: rund 2,2 Sekunden für eine einzige Datei.

Wir brauchen aber keinen vollständigen Patch. Wenn zwei 150.000-Zeilen-Dateien unterschiedlich sind, interessiert im Fehlerbericht zuerst die Stelle, an der sie auseinanderlaufen.

Seitdem sucht der Comparator zunächst die erste abweichende Zeile und erzeugt nur für ein 200-Zeilen-Fenster ab dieser Stelle einen Unified Diff. Die echten Zeilennummern landen trotzdem im Hunk-Header. Aus 2,2 Sekunden wurden 0,01.

Der Selbsttest ist anschließend als pytest-Test pro Szenario formuliert: zweimal spielen, Gleichwertigkeit verlangen, anschließend game.out des letzten Schritts mutieren und genau einen Unterschied erwarten. Die Tests tragen den Marker reference und werden übersprungen, wenn das C++-Binary nicht vorhanden ist.

Drei Minuten für nichts

Damit zurück zur Frage nach den CI-Kosten.

Meine Annahme war, dass die Referenz nur beim ersten Lauf kompiliert wird und danach aus dem Cache kommt. Der Agent hat nicht zugestimmt, sondern in die Job-Zeiten geschaut:

Job / Schritt Dauer
Python (gesamt) 16 s
Reference (gesamt) 3:05 min
davon Toolchain-Image aus dem Cache 14 s
davon Kompilieren der Referenz 2:35 min

Gecacht war das Docker-Image mit Ubuntu, GCC und CMake. Die kompilierte Referenz-Engine dagegen wurde bei jedem Push frisch gebaut. Das war seit dem Docker-Setup aus Teil 1 so; ich hatte nur angenommen, der Cache umfasse auch diesen Schritt.

Die Lösung ist ein eigener actions/cache-Eintrag für build/reference/. Sein Key enthält den Commit des Submoduls und einen Hash des Build-Rezepts. Solange sich weder Referenz noch Build ändern, kann der Kompilierschritt komplett entfallen.

Das Toolchain-Image wird weiterhin gebaut, kommt aber aus dem Docker-Layer-Cache und braucht rund 14 Sekunden. Das Harness benötigt es weiterhin als Laufzeitumgebung für das Referenz-Binary. Danach installiert der Job uv und führt pytest -m reference aus.

Der erste Lauf mit diesem Cache zeigte gleich die nächste Eigenheit von GitHub Actions: Caches haben einen Scope.

Ein Cache, der während eines pull_request-Runs entsteht, wird dem Merge-Ref refs/pull/<n>/merge zugeordnet. Genau das geschah bei unserem Pull Request 9. Der anschließende Push auf main konnte diesen Cache nicht wiederherstellen und kompilierte die Referenz deshalb noch einmal.

Der dabei auf main erzeugte Cache ist dagegen für spätere Pull Requests erreichbar. Der nächste PR traf ihn: Der Reference-Job sank von 3:24 Minuten auf 56 Sekunden, der Kompilierschritt stand auf skipped.

Auch der Selbsttest war in der CI deutlich schneller als auf meinem Windows-Rechner: 18 statt 49 Sekunden. Auf dem Linux-Runner gibt es den Umweg über Docker Desktop nicht.

Der Zustandsdump

Der letzte Pull Request dieses Schritts enthält keine Zeile Python. Er definiert nur das Format des späteren Zustandsdumps.

Der Grund dafür steckt in game.out. Ein roher Unterschied sieht dort ungefähr so aus:

Zeile 84.213: 12 gegen 13

Für die Fehlersuche wäre etwas wie

Region 217, Einheit 442, Gegenstand SILV: 12 gegen 13

wesentlich hilfreicher.

Deshalb sollen beide Engines ihren Zustand später zusätzlich strukturiert schreiben können. Die Referenz bekommt zunächst einen Konverter für game.out; ab Schritt 11.0 soll sie den Dump direkt nach einzelnen Zugphasen erzeugen können. Der Python-Port kann ihn aus seinen eigenen Objekten schreiben.

Für das Format hat der Agent Game::SaveGame und die von dort aufgerufenen Writeout-Methoden gelesen und die gespeicherten Felder systematisch abgebildet. Vier Regeln habe ich einzeln freigegeben: Kleine Enums werden mit Namen statt Zahlen geschrieben, Platzhalter der Engine werden zu null, Listen behalten die Reihenfolge der Engine und regelwerksabhängige Felder folgen denselben Schaltern wie im Original.

Beim Lesen fiel dabei ein Detail auf, das für den Port wichtiger ist, als es zunächst aussieht.

Der „Seed“ in game.out ist nicht der Seed, mit dem die Welt erzeugt wurde. Noch mehr: Die Engine speichert auch nicht den vollständigen Zustand ihres MT19937.

Game::SaveGame() zieht stattdessen beim Speichern einen Wert mit rng::get_random(10000) und schreibt diese Zahl in game.out. Beim nächsten Zug liest Game::OpenGame() sie wieder ein und ruft rng::seed_random(seed) auf.

Der Zustand zwischen zwei Zügen wird also bewusst auf einen Wert von 0 bis 9999 reduziert und daraus beim Laden ein neuer Generatorzustand aufgebaut. Die Ziehung dieses Wertes gehört selbst zum Zufallsstrom.

Das macht das Feld für den Vergleich nützlich: Wenn zwei Engines bis zum Speichern unterschiedlich viele Zufallswerte verbraucht haben, ist die Chance groß, dass hier bereits verschiedene Zahlen stehen. Ein eindeutiger Fingerabdruck ist es mit nur 10.000 möglichen Werten allerdings nicht; zwei unterschiedliche Generatorzustände können denselben gespeicherten Seed liefern.

Noch ein paar Dinge standen ebenfalls nicht in der Schnittstellendokumentation: Nachbarn einer Region liegen nicht in ihrem Regionsblock, sondern in einem separaten Block am Ende. MAX_READY ist 4, nicht 2. Und ob eine Fraktion ein NPC ist, wird nicht direkt gespeichert, sondern beim Laden aus -1-Werten rekonstruiert. Ein Kommentar in faction.cpp nennt das selbst „bogus“.

Am Harness musste dafür nichts geändert werden. state.json endet auf .json und durchläuft damit dieselbe JSON-Normalisierung wie die Berichte. Ein Test hält dieses Verhalten fest.

Stolperfallen

  • Ein Vergleich muss beweisen, dass er „ungleich“ sagen kann. Referenz gegen Referenz ist nur die halbe Prüfung. Eine absichtliche Mutation muss zuverlässig als Unterschied im Fehlerbericht ankommen.
  • Kaputtes JSON ist ein Ergebnis, kein Grund abzustürzen. Dass der Agent bei der ersten Mutation ungültiges JSON erzeugte, war sein Fehler. Dass der Comparator damit nicht umgehen konnte, war ein Fehler im Harness.
  • Ein vollständiger difflib-Diff ist für 150.000 Zeilen Zahlen unnötig. SequenceMatcher kann bei großen, stark repetitiven Sequenzen teuer werden. Für einen Testfehler reicht ein kleines Fenster um die erste Abweichung und ist nebenbei leichter zu lesen.
  • „Das ist doch gecacht“ ist eine Messung wert. Das Docker-Image war gecacht, die kompilierte Engine nicht. Ein Blick auf die einzelnen CI-Schritte zeigte 2:35 Minuten Kompilierzeit bei jedem Push.
  • GitHub-Caches sind nicht global für das Repository. Ein Cache aus einem pull_request-Run gehört zum Merge-Ref und kann von main nicht wiederhergestellt werden. Caches des Default-Branches können dagegen von Pull Requests genutzt werden.
  • Relative Pfade sind für Docker-Bind-Mounts keine gute Idee. Ein --out build/foo ließ docker run mit Exit-Code 125 aussteigen. Der Runner löst das Arbeitsverzeichnis deshalb vor dem Mount auf einen absoluten Pfad auf.
  • Docker Desktop ist nicht der Linux-Runner. Derselbe Selbsttest brauchte lokal unter Windows 49 Sekunden und in der CI 18. Für die eigentliche Engine-Laufzeit sind deshalb die Messwerte innerhalb des Containers aussagekräftiger als die Wall Time des gesamten lokalen Harness-Laufs.

Der Stand nach Teil 3

Drei Pull Requests, weiterhin keine portierte Zeile Spiellogik.

Dafür gibt es jetzt ein Harness mit drei Szenarien, drei Weltgenerierungen und sechs gespielten Zügen. Es vergleicht insgesamt 48 Artefakte und erkennt die Referenz gegen sich selbst als gleichwertig. Eine veränderte Zeile in game.out fällt auf, ebenso ein geänderter JSON-Wert. Ungültiges JSON wird als Befund gemeldet, statt den Vergleich abzubrechen.

41 schnelle Tests laufen in 0,3 Sekunden, vier Referenztests mit Docker in 18 Sekunden in der CI. Mit warmem Cache braucht der gesamte Reference-Job 56 Sekunden statt mehr als drei Minuten. docs/comparison.md hält fest, welche Unterschiede normalisiert werden und wie der spätere Zustandsdump aussieht.

Die Vergleichsquote steht damit bei 100 Prozent von drei Szenarien. Noch ist das wenig beeindruckend, weil ausschließlich die Referenz gegen sich selbst antritt.

Die Portierungsabdeckung bleibt bei 0 Prozent von 47.949 Zeilen.

In Teil 4 ändert sich das zum ersten Mal. Wir portieren rng.hpp, den Zufallsgenerator, der in Teil 2 als eine der Grundlagen für alles weitere aufgefallen ist. Das Done-Kriterium ist passend streng: Für definierte Seeds muss Python exakt dieselben Zahlenfolgen liefern wie C++, Ziehung für Ziehung.

Weiterlesen

Sources: geekblogio/atlantis-pbem-engine, dessen CLI-Dokumentation, Dateiformate, Snapshot-Tests und game.cpp, Python difflib, GitHub Actions: Dependency caching, actions/cache.

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!