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 newerzeugt eine Welt und schreibtgame.outundplayers.out. MitATLANTIS_SEEDlässt sich der Seed für die Weltgenerierung festlegen. Dasstandard-Regelwerk fragt beinewnichts über stdin ab.standard runliestgame.in,players.inund vorhandeneorders.<n>und schreibt unter anderemgame.out,players.out,report.<n>,report.<n>.json,template.<n>und gegebenenfallstimes.<zufall>. Das Umbenennen von*.outnach*.infür den nächsten Zug ist Sache des Aufrufers.report.<n>.jsonist 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.outist 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: …undWyreth, Version: …. report.<n>.jsonwird als JSON geparst und mit sortierten Schlüsseln wieder serialisiert. Die drei Versionsstrings unterenginewerden 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.SequenceMatcherkann 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 vonmainnicht 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/fooließdocker runmit 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
- Atlantis PbeM neu geschrieben (Teil 2)
– die Karte des Altcodes und warum
rng.hppvollständig portiert wird. - Determinismus testen: Zufall, Zeit & I/O kontrollieren – ohne eine deterministische Referenz gäbe es nichts Verlässliches zu vergleichen.
- pytest von Null – Marker, Fixtures und
tmp_path, wie sie der Selbsttest benutzt.
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!
Anmelden um einen Kommentar zu hinterlassen.