Zum Inhalt springen

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_MAX enden durch Modulo-Reduktion und die Null-Regel von minstd_rand beim 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::round rundet 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 ein char unverändert an isalnum oder tolower. Bei negativen char-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

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!