cat posts/atlantis-pbem-neu-geschrieben-04-zufall-deterministisch.md
Atlantis PbeM neu geschrieben (Teil 4): Zufall, deterministisch – derselbe Strom, Ziehung für Ziehung
Die ersten portierten Zeilen: der Zufallsgenerator, der in Python exakt dieselben Zahlen liefern muss wie in C++. Teil 4 zeigt, warum die Testvektoren aus der Referenz kommen, was Seeds 0, 1 und INT_MAX gemeinsam haben und was Python-Zufall an Laufzeit kostet.
In Teil 3 entstand der Richter: ein Harness, das die C++-Referenz auf festen Szenarien spielt und ihre Ausgaben vergleicht. Die Portierungsabdeckung stand danach weiterhin bei null Prozent. In diesem Teil bekommt sie zum ersten Mal einen Wert.
Schritt 3 der Roadmap heißt „Basic building blocks“: die unterste Schicht, von der der
Rest später abhängt. Im Kern ist das rng.hpp, das in
Teil 2 als erste zu
portierende Datei feststand. Dazu kommen die String-Helfer, mit denen die Engine Namen
filtert und Befehlszeilen zerlegt.
Das Done-Kriterium für den Zufall ist knapp und streng: Für eine festgelegte Menge von Seeds muss Python exakt dieselben Zahlenfolgen liefern wie C++.
Warum ausgerechnet damit anfangen? Weil der Zufallsstrom darüber entscheidet, ob das Harness aus Teil 3 später überhaupt brauchbare Fehler liefert. Atlantis PbeM zieht an vielen Stellen Zufallszahlen: im Kampf, bei Monstern, bei der Weltgenerierung und in weiteren Spielmechaniken. Verbraucht der Port irgendwo eine Ziehung mehr oder weniger, ist ab diesem Punkt jede folgende Zufallsentscheidung verschoben.
Das Harness meldet dann zwar Unterschiede, aber die Ursache liegt möglicherweise hunderte Ziehungen vorher. Ein identischer Zufallsstrom ist deshalb keine Feinheit, sondern Voraussetzung für einen sinnvollen Vergleich.
Was in rng.hpp steckt
Der Header hat 499 Zeilen, davon 306 Code. Der Fork hat einen großen Teil der
Zufallsverteilungen von libstdc++ von Hand nachgebaut. Der Grund dafür ist in
rng.hpp ausführlich dokumentiert: std::mt19937 selbst ist reproduzierbar
spezifiziert, die Algorithmen der Verteilungen darüber dagegen nicht so, dass
verschiedene Standardbibliotheken denselben Rohdatenverbrauch garantieren.
libstdc++ und libc++ können deshalb bei derselben Operation unterschiedlich viele Werte aus dem MT19937 ziehen. Für einen normalen Zufallsgenerator wäre das kaum bemerkenswert. Für Atlantis PbeM bedeutet eine zusätzliche Ziehung, dass der gesamte folgende Strom verschoben ist.
Für den Python-Port ist diese Vorarbeit praktisch. Die Algorithmen, die wir nachbilden müssen, stehen bereits explizit im Header.
Der Agent zählte zunächst, welche Teile davon das standard-Regelwerk überhaupt
benutzt. Im Build gibt es 248 Aufrufstellen von get_random, 52 von one_of, 18 von
make_roll, vier von seed_random, zwei von shuffle sowie jeweils eine von
get_weighted_index und calculate_losses. Zusammen sind das 326 Stellen, die später
beim Portieren wiedergefunden werden müssen.
Nur generator(), das direkten Zugriff auf den MT19937 gibt, wird im betrachteten Build
nirgendwo aufgerufen. Diese Funktion braucht der Python-Port nicht.
Das Seeding besteht aus mehreren Stufen. Der C++-Seed ist ein int. Er wird zunächst
vorzeichenerweitert, als 64-Bit-Wert interpretiert und anschließend modulo 2³¹ − 1
reduziert. Dieser Wert seedet std::minstd_rand, einen linearen Kongruenzgenerator.
Sechs Ausgaben daraus gehen in std::seed_seq, das daraus wiederum die 624
Zustandswörter des MT19937 erzeugt.
Darüber liegen die eigentlichen Verteilungen. uniform_below implementiert Lemires
Multiply-Shift-Verfahren so, wie es die Referenz für libstdc++ eingefroren hat. Im
Normalfall kostet eine begrenzte Ganzzahl eine Rohziehung. Auch bound == 1 verbraucht
absichtlich eine, obwohl das Ergebnis zwangsläufig 0 ist.
canonical baut aus zwei Rohziehungen einen double in [0, 1). shuffle übernimmt
die Paarungsoptimierung von libstdc++, bei der zwei Swap-Indizes aus einer Ziehung
gewonnen werden können, solange der Wertebereich klein genug ist.
Die Binomialverteilung hinter calculate_losses hat zwei Pfade. Entscheidend ist dabei
nicht einfach t * p, sondern t * min(p, 1 - p): Unterhalb von 8 verwendet die
Referenz eine Wartezeit-Methode, darüber Devroyes Rückweisungsverfahren. Letzteres
braucht zusätzlich eine Normalverteilung mit eigenem Cache.
Dieser Bereich ist auch die Ausnahme im sonst weitgehend ganzzahligen Zufallscode. Hier
kommen log, exp, sqrt und lgamma ins Spiel. Damit hängt das Ergebnis potenziell
auch von Floating-Point- und Mathematikbibliotheksdetails ab.
Drei Entscheidungen vor dem Code
Wie in den Teilen davor begann der Schritt mit einem Vorschlag und offenen Fragen.
Was übernimmt Python, was wird ausgeschrieben? CPythons random.Random verwendet
ebenfalls MT19937, und der Kern steckt bereits in C. Für getrandbits(32) liefert er
genau einen 32-Bit-Wert aus diesem Generator. Es wäre wenig sinnvoll, denselben
Generator noch einmal langsam in Python zu implementieren.
Also leihen wir uns nur diesen Kern. Seeding und Verteilungen werden selbst implementiert.
Für das Setzen des Zustands nutzt der Port CPythons internes MT-Format: 624
Zustandswörter plus Index. Das funktioniert über setstate(), ist aber eine bewusst
eingegangene CPython-Abhängigkeit. Die öffentliche Python-Dokumentation garantiert nur,
dass ein von getstate() gelieferter Zustand später wieder an setstate() übergeben
werden kann; die konkrete Struktur des Tupels ist kein allgemeiner Python-Vertrag.
Für dieses Projekt ist das akzeptabel. Der Port läuft ohnehin auf CPython, und Tests machen einen Bruch dieser Annahme sofort sichtbar. Die Abhängigkeit steht deshalb ausdrücklich im Modul und in der Porting-Map.
Gehören die String-Helfer mit in diesen Schritt? Ja. string_filters.hpp,
strings_util.hpp und string_parser.hpp sind klein, haben keine Abhängigkeiten in die
eigentliche Spiellogik und werden bereits im nächsten Schritt von den Datentabellen
gebraucht. graphs.h und die Dateien, die durch Python-Standardmittel ersetzt werden,
können warten, bis ihre ersten Aufrufer portiert werden.
Wie sieht die Schnittstelle aus? Eine Klasse Rng, die das spätere Game-Objekt
besitzt und an seine Abhängigkeiten weiterreicht. Damit verschwindet der globale
Generator aus der C++-Version. Die Methodennamen bleiben dagegen erhalten:
get_random, make_roll, one_of, shuffle, calculate_losses und
get_weighted_index.
So lässt sich beim Portieren jede der 326 Aufrufstellen direkt zuordnen.
Ein Risiko blieb zunächst offen: calculate_losses hängt als einziger Bereich in
rng.hpp von mehreren Gleitkommafunktionen ab. Die Referenz hatte bereits gezeigt, dass
ihre nachgebaute Binomialverteilung auf Linux und macOS für eine große Testmenge
denselben Zufallsstrom produziert. Für Python musste trotzdem geprüft werden, ob die
anderen Implementierungen der mathematischen Funktionen an einer
Rückweisungsentscheidung vorbeirunden.
Erst die Vektoren, dann der Port
Der erste Pull Request dieses Schritts enthält noch keine Zeile des Python-Ports. Er enthält ein C++-Programm.
tools/reference/rng_probe.cpp bindet das schreibgeschützte rng.hpp direkt ein und
wird vom Build-Skript im selben Docker-Image kompiliert wie die Referenz-Engine, mit
denselben Compiler-Flags.
Die Sonde schreibt JSON. Sie prüft elf Seeds, darunter 0, 1, INT_MAX, −1 und
−559038737. Pro Seed zeichnet sie 122 Operationen auf: einen langen Block mit 700
Rohwerten, get_random für verschiedene Bereiche bis INT_MAX, einen negativen
Bereich, Würfe, one_of, zehn Shuffle-Größen, elf Gewichtskombinationen und ein Raster
für calculate_losses.
Wichtig ist dabei weniger die Anzahl als die Reihenfolge: Alle Operationen eines Seeds laufen auf demselben Generator weiter. Es wird zwischendurch nicht neu geseedet.
Verbraucht der Python-Port bei irgendeiner Operation eine Rohziehung zu viel, schlägt nicht nur diese eine Prüfung fehl. Auch alles danach stimmt nicht mehr. Genau das wollen wir. Fehler im Zufallsstrom sollen möglichst laut werden.
Die Ausgabe liegt als tests/fixtures/rng-probe.json im Repository und ist 283 KB
groß. Ein Test mit dem Marker reference baut die Sonde frisch aus dem gepinnten
Submodule und vergleicht ihre Ausgabe mit der eingecheckten Fixture. Ändert sich die
Referenz, fällt das auf. Der komplette Referenz-Build inklusive Sonde braucht lokal
39 Sekunden.
Die Testvektoren kommen damit aus der C++-Referenz selbst, nicht aus unserer Interpretation ihres Codes. Das ist ein wichtiger Unterschied: Ein Test gegen selbst aus dem Algorithmus hergeleitete Sollwerte kann nur bestätigen, dass Port und Test denselben Denkfehler enthalten.
Beim Erzeugen der Fixture zeigte sich prompt ein Verhalten, das ich vorher nicht auf dem Schirm hatte.
Ein Test erwartete elf verschiedene Zahlenströme für elf Seeds und bekam nur neun. Die
Seeds 0, 1 und INT_MAX liefern denselben Strom.
Die Erklärung steckt in std::minstd_rand. Sein Modulus ist 2³¹ − 1, also genau
INT_MAX. Nach unserer vorgeschalteten Reduktion werden deshalb sowohl 0 als auch
INT_MAX zu 0. Bei einem multiplikativen linearen Kongruenzgenerator mit
increment == 0 darf der interne Zustand aber nicht 0 sein: Wenn der Seed modulo
Modulus 0 ergibt, setzt linear_congruential_engine ihn auf 1.
Seed 1 startet ohnehin mit diesem Zustand.
Drei Eingaben, derselbe Generatorzustand.
Der Test hält das inzwischen ausdrücklich fest:
# tests/test_rng_fixture.py
def test_seeds_zero_one_and_int_max_share_a_stream() -> None:
probe = json.loads(FIXTURE.read_bytes())
streams: dict[tuple[int, ...], list[int]] = {}
for entry in probe["seeds"]:
streams.setdefault(tuple(entry["ops"][0]["out"][:8]), []).append(entry["seed"])
assert [seeds for seeds in streams.values() if len(seeds) > 1] == [[0, 1, 2147483647]]
Wer eine Welt mit Seed 0, 1 oder 2147483647 erzeugt, bekommt bei identischen übrigen Eingaben also denselben Zufallsstrom. Der Port reproduziert dieses Verhalten bewusst.
Der Port
src/atlantis_ng/core/rng.py hat 407 Zeilen, Docstrings eingerechnet. Das Seeding
beginnt so:
# src/atlantis_ng/core/rng.py
def seed_random(self, seed: int | None = None) -> None:
device = secrets.randbits(32) if seed is None else seed & MASK64
reduced = device % MINSTD_MODULUS
minstd = _minstd(reduced)
self._set_state(_seed_seq_generate([minstd() for _ in range(6)], MT_STATE_WORDS))
Bei einem expliziten Seed bildet seed & MASK64 das Ergebnis der zweistufigen
C++-Konvertierung auf einen vorzeichenlosen 64-Bit-Wert nach. Für -1 entsteht damit
beispielsweise 2**64 - 1. Anschließend folgt dieselbe Modulo-Reduktion wie in der
Referenz.
_minstd implementiert den kleinen Kongruenzgenerator. _seed_seq_generate bildet
std::seed_seq::generate in knapp 30 Zeilen Python nach. Die daraus erzeugten 624
Wörter werden mit Index 624 in den CPython-MT19937 eingesetzt. Damit führt die erste
Ziehung wie in C++ zunächst den nächsten Twist des Zustands aus.
Die Verteilungen orientieren sich anschließend direkt am Referenzcode. uniform_below
sieht zum Beispiel so aus:
# src/atlantis_ng/core/rng.py
def _uniform_below(self, bound: int) -> int:
x = self._mt.getrandbits(32)
m = (x * bound) & MASK64
low = m & MASK32
if low < bound:
threshold = (0x100000000 - bound) % bound
while low < threshold:
x = self._mt.getrandbits(32)
m = (x * bound) & MASK64
low = m & MASK32
return m >> 32
Die 64-Bit-Maske ist für die tatsächlichen Aufrufer nicht nötig: Die Engine übergibt
keine Grenze, bei der das Produkt den Bereich eines uint64_t verlassen könnte.
Trotzdem bleibt sie im Port stehen, weil sie die C++-Arithmetik sichtbar macht. Python
würde mit seinen beliebig großen Ganzzahlen sonst stillschweigend ein anderes
Zahlenmodell benutzen.
Dann kam der Testlauf. Ein parametrisierter Test spielt für jeden der elf Seeds alle 122 aufgezeichneten Operationen in derselben Reihenfolge nach.
Alle 1.342 Operationsblöcke stimmten beim ersten vollständigen Lauf überein. Das galt
auch unter Windows für die aufgezeichneten calculate_losses-Fälle mit
math.log, math.exp, math.sqrt und math.lgamma. Die acht zusätzlichen Sequenzen
aus rng_test.cpp des Forks bestehen ebenfalls.
Das ist ein stärkeres Ergebnis, als ich beim Binomialcode erwartet hatte, aber keine
Garantie für beliebige Plattformen und Eingaben. CPython bringt für math.lgamma eine
eigene Lanczos-Implementierung mit, statt einfach das lgamma der Plattform
durchzureichen. Darin und in den übrigen Python-Mathematikfunktionen stecken aber
weiterhin elementare Funktionen der C-Mathematikbibliothek. Die getesteten
Rückweisungsentscheidungen waren identisch; mehr behauptet der Test nicht.
Ganz ohne Sonderfälle kommt der Port trotzdem nicht aus. Zwei Unterschiede zwischen C++ und Python mussten bewusst nachgebaut werden.
std::round rundet halbe Werte vom Nullpunkt weg. Pythons round verwendet dagegen
Round-to-even:
# src/atlantis_ng/core/rng.py
def c_round(value: float) -> float:
"""C's ``round``: halfway cases away from zero (Python's ``round`` goes to even)."""
magnitude = abs(value)
floor = math.floor(magnitude)
rounded = floor + 1 if magnitude - floor >= HALF else floor
return math.copysign(float(rounded), value)
Damit wird aus 2,5 in C++ eine 3, während round(2.5) in Python 2 ergibt.
Der zweite Unterschied ist unscheinbarer: std::floor liefert einen Gleitkommawert,
math.floor einen Python-int. In der Rückweisungsschleife der Binomialverteilung wird
das Ergebnis anschließend mit weiteren Doubles verrechnet und später wie durch einen
C++-Cast abgeschnitten. Der Port hält x dort deshalb ausdrücklich als float.
Was der Python-Zufall kostet
Die Serie hat versprochen, die Kosten des Python-Ports zu messen statt zu schätzen. Beim RNG gibt es jetzt die ersten Zahlen vom Entwicklungsrechner.
Ein vollständiger get_random-Aufruf braucht dort etwa 0,24 µs. Eine nackte
32-Bit-Ziehung mit random.Random.getrandbits(32) liegt bei ungefähr 0,13 µs. Zum
Vergleich implementierte ich MT19937 einmal vollständig in Python; eine Rohziehung
brauchte dort etwa 0,94 µs.
Für den eigentlichen Generator ist der C-Kern von CPython damit in dieser Messung gut siebenmal schneller als die reine Python-Variante. Im Kick-off hatte der Agent ungefähr Faktor zehn geschätzt.
Für die Engine sagt das noch nicht viel. Wir wissen bislang nicht einmal, wie viele Zufallsziehungen ein kompletter realer Zug benötigt. Erst wenn genug Spiellogik portiert ist, lässt sich messen, ob der RNG überhaupt einen relevanten Anteil der Laufzeit ausmacht.
Strings mit ASCII-Regeln
Der dritte Pull Request portiert die String-Helfer, zusammen 331 Codezeilen C++. Die
Filter aus string_filters.hpp werden zu normalen Funktionen:
str | filter::capitalize wird beispielsweise capitalize(s). Dazu kommen join mit
einem optional anderen Trenner vor dem letzten Element, plural und ci_string, dessen
Vergleich Groß- und Kleinschreibung ignoriert und _ wie ein Leerzeichen behandelt.
Hier lauert ein anderer Unterschied zwischen Python und C++: Python-Strings sind
Unicode, der Altcode arbeitet mit char und den Funktionen aus <cctype>.
Im Default-C-Locale kennen isalnum, toupper und tolower nur die ASCII-Buchstaben
und -Ziffern. Pythons str.isalnum(), str.upper() und str.lower() würden dagegen
Unicode verarbeiten. Aus ß kann beispielsweise SS werden, und ein ä gilt für
Python selbstverständlich als Buchstabe.
Für den Port sind deshalb explizite ASCII-Helfer sinnvoll:
# src/atlantis_ng/core/text.py
def c_tolower(c: str) -> str:
"""``std::tolower`` in the C locale: ASCII upper-case letters only."""
return chr(ord(c) + 32) if "A" <= c <= "Z" else c
def ci_key(text: str) -> str:
return "".join(" " if c == "_" else c_tolower(c) for c in text)
An dieser Stelle hat der C++-Code allerdings selbst eine unschöne Kante. Nicht alle
Aufrufe von <cctype> konvertieren ihr char vorher zu unsigned char.
ci_traits::normalize macht es korrekt, andere Helfer wie legal_characters oder
lowercase teilweise nicht. Bei einem signed char und einem Byte oberhalb von 127 ist
das Verhalten von std::isalnum beziehungsweise std::tolower formal undefiniert.
Die Aussage „C++ macht mit jedem Nicht-ASCII-Zeichen exakt X“ wäre deshalb zu stark.
Unser Kompatibilitätsziel ist das Verhalten der gepinnten Referenz auf ihrer
Referenzplattform, nicht irgendein hypothetischer C++-Build mit anderer Locale oder
anderem char-Verhalten.
Der Python-Port legt die beabsichtigte ASCII-Regel explizit fest. Ein Test hält zum
Beispiel fest, dass legal_characters("Käse & Brot") in unserem Kompatibilitätsmodell
"Kse & Brot" ergibt. Das ist damit reproduzierbar, während der ursprüngliche C++-Code
für solche Bytes nicht überall dieselbe Garantie gibt.
Bei einer ähnlichen Stelle lag der Agent einmal im Test daneben, nicht im Port. Er erwartete:
lowercase("ÄRGER") == "ÄRGER"
Die Annahme war: Umlaut drin, also bleibt das Wort unangetastet. Die ASCII-Regel arbeitet
aber Zeichen für Zeichen. Das Ä bleibt stehen, die ASCII-Buchstaben RGER werden
kleingeschrieben. Das Ergebnis ist also "Ärger".
Zwei weitere kleine Verhaltensdetails wurden ebenfalls bewusst übernommen.
canonicalize macht aus summon wind ein Summon_Wind. Es trennt an einzelnen
Leerzeichen, weshalb zwei Leerzeichen auch zwei Trenner erzeugen. Pythons
str.split(" ") verhält sich hier passend, weil ein expliziter Separator leere Felder
zwischen zwei Separatoren erhält.
strip_number entfernt vor der Klammer mit der Einheitennummer genau ein
Whitespace-Zeichen, nicht beliebig viele. Der Kommentar im Original erklärt sogar
warum: Es gab Einheiten, deren Namen absichtlich mit Leerzeichen endeten, und dieses
Verhalten sollte nicht nachträglich geändert werden.
string_parser.hpp wird zu core/parser.py: ein Token, das wie ci_string
vergleicht, und ein StringParser, der eine Befehlszeile in Tokens zerlegt. Namen mit
Leerzeichen können in Anführungszeichen stehen, ; beginnt einen Kommentar und @
markiert Befehle, die in späteren Zügen wiederholt werden sollen.
get_number übernimmt auch die etwas ungewöhnliche Überlaufprüfung des Originals gegen
INT_MAX / 10 und INT_MAX % 10. Deshalb wird -2147483648 abgelehnt, obwohl dieser
Wert selbst noch in einen 32-Bit-int passt. Die Referenz prüft zunächst den positiven
Betrag gegen INT_MAX und wendet das Minuszeichen erst danach an.
# src/atlantis_ng/core/parser.py
@dataclass(frozen=True, eq=False)
class Token:
value: str | None
def __eq__(self, other: object) -> bool:
if isinstance(other, Token):
other = other.value
if isinstance(other, str):
return ci_equal(self.value or "", other)
return NotImplemented
eq=False verhindert, dass dataclass zusätzlich einen eigenen Gleichheitsvergleich
erzeugt. Token("MOVE") und Token("move") sollen schließlich nach den Regeln der
Referenz gleich sein.
Nicht portiert wurde operator>>, also das Lesen einer kompletten Zeile aus einem
Stream. Woher die Zeilen kommen, entscheidet später der Befehlsleser in Schritt 9.
Der Fork hat für die Filter bereits string_filters_test.cpp; dessen passende Vektoren
stehen jetzt auch in tests/core/test_text.py. Für string_parser.hpp existiert kein
isolierter C++-Unit-Test. Dort wird der Parser bisher indirekt über echte Befehle
geprüft. Die Python-Tests leiten sich deshalb aus dem Header und aus realen
Befehlszeilen wie #atlantis 3, claim 720 und buy 10 SELF ab.
Stolperfallen
- Testvektoren kommen aus der Referenz, nicht aus dem Kopf. Die Sonde läuft mit
demselben
rng.hpp, derselben Toolchain und denselben Compiler-Flags wie die Referenz-Engine. Ein Test gegen selbst aus dem Code hergeleitete Sollwerte kann denselben Irrtum enthalten wie der Port. - Ein durchgehender Zufallsstrom macht Fehler laut. Weil die 122 Operationsblöcke eines Seeds ohne Neuseeding aufeinander folgen, verschiebt eine zusätzliche Ziehung auch alle späteren Ergebnisse.
- Drei Seeds, ein Strom. 0, 1 und
INT_MAXenden durch Modulo-Reduktion und die Null-Regel vonminstd_randbeim selben Anfangszustand. - CPythons MT-Zustandsformat ist eine Implementierungsabhängigkeit.
setstate()ist öffentlich, die konkrete Darstellung aus 624 Wörtern und Index aber nicht als portabler Python-Vertrag dokumentiert. Ein Python-Upgrade muss deshalb durch die Fixture-Tests. - C++ und Python runden nicht immer gleich.
std::roundrundet halbe Werte vom Nullpunkt weg, Python zur nächsten geraden Zahl. - Gleichheit auf zwei Plattformen ist noch kein mathematischer Beweis. Die aufgezeichneten Binomialfälle stimmen unter Windows und Linux überein. Die beteiligten Gleitkommafunktionen bleiben trotzdem eine Stelle, die bei neuen Plattformen getestet werden muss.
<cctype>ist bei Nicht-ASCII heikler als „C-Locale = ASCII“. Einige Stellen der Referenz übergeben eincharunverändert anisalnumodertolower. Bei negativenchar-Werten ist das laut C++ nicht definiert. Der Port sollte dieses Problem nicht versehentlich als garantierte Referenzsemantik verewigen.- Der Agent kann einen Test falsch erwarten, obwohl der Port stimmt. Bei
"ÄRGER"war die falsche Erwartung leichter zu schreiben als die korrekte zeichenweise ASCII-Regel. Ein roter Test ist deshalb zuerst ein Befund, nicht automatisch ein Beweis gegen den Code.
Der Stand nach Teil 4
Drei Pull Requests, und zum ersten Mal enthält das Projekt tatsächlich portierte Spiellogik.
core/rng.py reproduziert die aufgezeichneten Ergebnisse aus rng.hpp für elf Seeds
und 1.342 Operationsblöcke, mit demselben fortlaufenden Zufallsstrom. core/text.py und
core/parser.py übernehmen die drei String-Header und machen deren ASCII-Regeln im
Python-Code explizit.
Die Portierungsabdeckung steht bei 637 von 47.949 Zeilen, 1,3 Prozent. Nach
rng.hpp allein waren es 306 Zeilen und 0,6 Prozent: der erste Wert über null.
128 schnelle Tests laufen in 0,5 Sekunden, 85 davon unter tests/core/. Die
Referenztests mit Docker bauen zusätzlich die RNG-Sonde und vergleichen ihre Ausgabe
mit der eingecheckten Fixture. Der erste Pull Request dieses Schritts änderte dafür das
Build-Rezept und musste die Referenz einmal neu kompilieren; beim zweiten stand der
Kompilierschritt in der CI wieder auf skipped.
Die Vergleichsquote bleibt bei 100 Prozent von drei Szenarien, weiterhin Referenz gegen Referenz. Die Python-Engine kann noch keinen Zug spielen.
Aber sie kann jetzt exakt so würfeln wie das Original.
In Teil 5 kommen die statischen Spieldaten: Gegenstände, Fertigkeiten, Terrain, Rassen,
Gebäude und Zauber, im Original als positionsgekoppelte Enums und globale Arrays. Das
Done-Kriterium ist wieder ein Vergleich gegen die C++-Referenz: Ein Dump der Tabellen
muss für standard dieselben Daten liefern.
Weiterlesen
- Atlantis PbeM neu geschrieben (Teil 3) – das Harness, für das dieser Teil die Voraussetzung schafft.
- Determinismus testen: Zufall, Zeit & I/O kontrollieren – warum ein Generator als Objekt durchgereicht wird statt als Global zu leben.
- Das Datenmodell: Dunder-Methoden verstehen –
__eq__und__hash__im Python-Datenmodell. - pytest von Null –
parametrizeund Marker, wie die Fixture-Tests sie benutzen.
Sources: rng.hpp
und die Entscheidungen 0019
und 0020
des Forks,
std::seed_seq,
std::linear_congruential_engine,
Lemire: Fast Random Integer Generation in an Interval,
Python random,
CPython _randommodule.c,
CPython mathmodule.c,
Python round,
std::isalnum
und std::tolower.
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.