cat posts/atlantis-pbem-neu-geschrieben-08-die-weltgenerierung.md
Atlantis PbeM neu geschrieben (Teil 8): Weltgenerierung – kein Zufallszug geht verloren
Aus einem Seed wird eine Welt: 2.049 Regionen, 115 Städte, 403 Einheiten. Teil 8 portiert die Weltgenerierung von Atlantis PbeM so, dass der Python-Port dieselbe Welt erzeugt wie das C++-Original – Region für Region, und mit denselben Macken.
Nach Teil 7 existiert der Objektgraph: Regionen, Gebäude, Einheiten und Fraktionen. Was fehlt, ist eine Welt, in der all das stehen kann.
Schritt 7 der Roadmap erzeugt sie. Game::NewGame baut aus einem Seed die Startebene,
den Nexus, und eine Oberfläche von 64 mal 64 Hexfeldern. Es entstehen Kontinente,
Terrain, Namen und Städte, Tore und Monsterhöhlen werden verteilt, zwei
Nichtspieler-Fraktionen gegründet und schließlich Stadtwachen und Monster in die Welt
gesetzt.
Das Done-Kriterium ist knapp:
Derselbe Seed liefert dieselbe Welt wie die Referenz – jede Region, jeder Markt, jedes Gebäude und jede Einheit.
Prozedurale Generierung ist dafür ein unangenehmer Fall. Jede Zufallsziehung hängt davon ab, wie viele vorher schon verbraucht wurden. Ein einziger zusätzlicher oder fehlender Aufruf, und ab diesem Punkt entsteht eine andere Welt: andere Küsten, andere Städte, andere Namen.
Ein Vergleich des Endzustands sagt dann zwar zuverlässig „falsch“, aber nicht unbedingt, wo die Abweichung begonnen hat.
Die Grundlage aus Teil 4 gilt weiterhin: Der Python-Port liefert für dieselben Seeds denselben Zufallsstrom wie die C++-Referenz. In diesem Teil geht es um die zweite Hälfte des Problems: dafür zu sorgen, dass dieser Strom an denselben Stellen und in derselben Reihenfolge verbraucht wird.
Erst lesen, dann zuschneiden
Die Roadmap hatte unter Weltgenerierung zunächst auch mapgen.cpp, simplex.cpp und
namegen.cpp eingeordnet: Höhenmodelle, Simplex-Rauschen, Regensimulation und einen
anderen Namensgenerator.
Beim Lesen des tatsächlichen Aufrufpfads stellte sich heraus: standard benutzt nichts
davon.
Diese Dateien und rund 1.200 Zeilen aus aregion.cpp gehören zu
create_natural_surface_level. Das verwenden andere Regelwerke wie kingdoms und
neworigins, aber nicht das Ziel dieses Ports.
standard nimmt den klassischen Generator aus standard/map.cpp: Land verteilen,
Wasser bereinigen, Terrain-Anker setzen und wachsen lassen, Landbrücken kappen,
Küstenseen entfernen und anschließend jede Region mit Produkten, Bevölkerung, Rasse,
Städten und Märkten füllen.
Der Scope wurde deshalb auf das reduziert, was Game::NewGame für standard
tatsächlich erreicht.
Vor dem ersten Portieren habe ich die Zufallsaufrufe auf diesem Pfad gezählt:
standard/map.cpp 43, standard/world.cpp 11, npc.cpp 28, economy.cpp 60,
game.cpp 8 und aregion.cpp 40.
Rund 190 Stellen also, an denen die Reihenfolge stimmen muss.
Am Anfang standen sechs Entscheidungen: der genaue Scope, das Vergleichsorakel, das Ruleset-Interface, das Logging, die Aufteilung in vier Pull Requests und die Frage, was „dieselbe Welt“ bedeutet.
Für Letzteres gilt: der vollständige Zustand direkt nach NewGame, Stadtwachen und
Monster eingeschlossen.
Die Weltgeneratoren der anderen Regelwerke wandern ans Ende der Roadmap.
Das Orakel: eine ganze Welt in 661 KB
Die Modellsonde aus Teil 7 kann bereits eine C++-Welt erzeugen. Ihr Weltdump enthält aber nur für einen Teil der Regionen den vollständigen Zustand.
Für ein Done-Kriterium mit „jede Region“ reicht das nicht.
Die Sonde bekam deshalb einen dritten Modus:
model_probe full
Er ruft Game::NewGame mit dem Seed des Vergleichs-Harnesses aus
Teil 3 auf und schreibt die
komplette Welt.
Jede Region landet als kompakte positionale Zeile im JSON: Stadt, Nachbarn, Produkte,
Märkte, Objekte und Einheiten. Dazu kommen die Fraktionen, wichtige Zähler und eine
weitere Zufallsziehung nach NewGame.
Diese letzte Zahl ist kein eindeutiger Fingerabdruck des kompletten
Generatorzustands – einzelne Ausgaben können kollidieren. Sie ist ein zusätzlicher
Kontrollwert dafür, dass beide Implementierungen den Zufallsstrom bis zum Ende von
NewGame gleich weit verbraucht haben.
Die erste Schätzung für die Fixture lag bei ein bis zwei Megabyte.
Gemessen wurden 660.752 Bytes für 2.049 Regionen, 403 Einheiten, 163 Lairs, 115 Städte und 32 Tore.
Dass es weniger wurde, liegt am Format. Die Namen der Felder stehen einmal im Kopf der Fixture, nicht in jeder Region.
Ein Test pinnt ihre Struktur. Ein zweiter, mit reference markiert, erzeugt die Welt
frisch mit der C++-Referenz und vergleicht die Fixture mit deren Ausgabe.
Damit kann selbst das Orakel nicht unbemerkt driften.
Woher weiß eine Region, ob sie Startstadt ist?
Ein Teil der Architektur von Atlantis PbeM lässt sich nicht direkt von C++ nach Python übersetzen.
Die Engine deklariert Funktionen in game.h und aregion.h, definiert sie aber nicht
selbst. Jedes Regelwerk bringt seine eigene world.cpp und monsters.cpp mit.
IsStartingCity, GetWeather, die Ebenenskalen oder CreateWorld werden dadurch beim
Linken durch das jeweilige Regelwerk geliefert.
Polymorphie zur Linkzeit.
Entscheidung 0001 aus
Teil 2 hatte dafür bereits
ein Python-Protocol vorgesehen. Jetzt wird es gebraucht:
# src/atlantis_ng/core/ruleset.py
class Ruleset(Protocol):
"""What the engine asks of a ruleset."""
# Docstrings der Methoden hier weggelassen.
def modify_tables(self, data: GameData, defs: GameDefs) -> None: ...
def get_name(self, town: bool, region: ARegion) -> str: ...
def get_level_x_scale(self, game: Game, level: int) -> int: ...
def get_level_y_scale(self, game: Game, level: int) -> int: ...
def get_weather(self, regions: ARegionList, region: ARegion, month: int) -> int: ...
def is_starting_city(self, region: ARegion) -> bool: ...
def is_safe_region(self, region: ARegion) -> bool: ...
def create_world(self, game: Game) -> bool: ...
def create_v_mons(self, game: Game) -> None: ...
def grow_v_mons(self, game: Game) -> None: ...
Game bekommt dafür ein weiteres Feld: ruleset.
Eine Region fragt nun beispielsweise
self.game.ruleset.is_starting_city(self)
so wie sie seit Teil 7 über self.game.defs auf die Einstellungen zugreift.
Die erste Fassung des Protokolls war größer. Sie enthielt auch GetRegType,
CheckRegionExit und mehrere create_*_level-Funktionen.
Beim Lesen der Aufrufer wurde klar, dass diese Funktionen ausschließlich innerhalb des
Regelwerks benutzt werden. Sie sind Implementierungsdetails von standard, keine
Schnittstelle zwischen Engine und Regelwerk.
Im Protocol bleiben deshalb nur die zehn Methoden, die die Engine tatsächlich aufruft.
Der Namenspool bekommt einen Besitzer
Auch die Regionsnamen zeigen, wie C++ und Python unterschiedlich mit Zustand umgehen.
standard/world.cpp hält die Namensverwaltung in dateilokalen static-Variablen:
// reference/standard/world.cpp
static int nnames;
static int * nameused;
static int ntowns;
static int nregions;
SetupNames() legt nameused beim Erzeugen einer Welt neu an, setzt alle Einträge auf
0 und setzt auch die Zähler zurück. Die vergebenen Namen werden also nicht einfach von
einer Welt zur nächsten weitergetragen.
Trotzdem bleibt der Zustand global für dieses Übersetzungsmodul und sein Besitz ist nirgendwo im Typmodell sichtbar.
Im Python-Port gehört er deshalb dem StandardRuleset als NamePool. Jedes neu
erzeugte Ruleset-Objekt erhält seinen eigenen Pool.
Das ändert das Ergebnis einer einzelnen Welt nicht. Es macht lediglich explizit, wem dieser Zustand gehört.
Beim Portieren fiel dabei eine falsche Zahl in der Portierungskarte auf. Dort standen 1.717 Namen.
Tatsächlich enthält regionnames 2.000: tausend für Städte und tausend für Regionen.
Die erste Höhle heißt Dummy
Der erste Pull Request portierte das Setup einer einzelnen Region: Produkte, Bevölkerung, Rasse, Stadt, Märkte und Lairs.
Die Sonde ließ dafür Setup() auf einer handgebauten Welt mit 73 Regionen und festem
Seed laufen und schrieb jede Region vollständig heraus.
Der erste Vergleich brach an der ersten Monsterhöhle ab.
Der Python-Port hatte ihr einen sinnvollen Namen gegeben.
Die C++-Referenz nennt sie Dummy.
Der Grund steckt in MakeLair:
// reference/aregion.cpp
void ARegion::MakeLair(int t)
{
Object *o = new Object(this);
o->num = buildingseq++;
o->set_name(ObjectDefs[t].name);
o->type = t;
...
}
Object startet nach seinem Konstruktor als O_DUMMY mit dem Namen Dummy.
Und set_name prüft gleich am Anfang:
// reference/object.cpp
if (type == O_DUMMY) return; // Cannot modify dummy objects, ever
MakeLair versucht also zuerst, den Namen zu setzen, und ändert erst danach den Typ.
Beim Aufruf von set_name hält sich das Objekt noch für ein Dummy und lehnt die
Umbenennung ab.
Jeder so erzeugte Lair behält deshalb den Namen Dummy.
In unserer vollständigen Referenzwelt sind das 163 Objekte.
Besonders schön ist, dass dieselbe Codebasis das Problem an anderer Stelle bereits
kennt. Beim Erzeugen der Gateways im Nexus steht in standard/map.cpp:
// Set the type first: set_name() refuses to rename an
// O_DUMMY object, which is what the constructor leaves it as.
o->type = O_GATEWAY;
o->set_name("Gateway to " + std::string(TerrainDefs[type].name));
Dort wurde die Reihenfolge also bewusst korrigiert.
In MakeLair nicht.
Ein sinnvoll benannter Lair wäre im Python-Port trotzdem falsch, denn der Name erscheint
später in Spielerberichten. Deshalb reproduziert make_lair die Reihenfolge der
Referenz, mit einem Kommentar, warum.
Dasselbe Muster taucht bei den Schächten zwischen Ebenen und bei der Schwarzen Feste des
Abgrunds auf. Auch dort wird der Name gesetzt, solange das neue Objekt noch
O_DUMMY ist.
Sie heißen deshalb ebenfalls Dummy.
Das zu ändern wäre keine Portierung mehr, sondern eine Änderung des Spiels. Wenn sie kommt, dann bewusst und getrennt.
Ein Index vor dem Array
Die nächste Eigenheit ist unangenehmer.
SetupHabitat wählt aus, welche Rasse in einer Region lebt. Küstenregionen dürfen neben
den normalen Rassen auch Küstenrassen bekommen:
// reference/economy.cpp
int n = rng::get_random(IsCoastal() ? allraces : noncoastalraces);
if (n > noncoastalraces-1) {
race = terrain->coastal_races[n-noncoastalraces-1];
} else
race = terrain->races[n];
Für
n == noncoastalraces
wird daraus
coastal_races[-1]
Der Code greift also vor den Anfang des Arrays.
Das ist in C++ undefiniertes Verhalten. Aus dem Quelltext allein lässt sich deshalb nicht portabel ableiten, welcher Wert dabei herauskommt.
Für unsere gepinnte Referenz lässt es sich aber beobachten: Der betreffende Zugriff
liefert dort den letzten Eintrag des unmittelbar davor liegenden races-Arrays. Durch
den um eins verschobenen Indexbereich wird gleichzeitig der letzte Eintrag von
coastal_races nie ausgewählt.
Der Python-Port bildet genau diesen beobachteten Effekt ab, ohne irgendeinen Out-of-Bounds-Zugriff nachzustellen:
# src/atlantis_ng/core/economy.py
if n > noncoastal - 1:
# The reference indexes coastal_races[n - noncoastal - 1]: for n == noncoastal that
# is one before the array, which is the last of races (the arrays are adjacent),
# and the last coastal race is never drawn.
index = n - noncoastal - 1
chosen = terrain.races[noncoastal + index] if index < 0 else terrain.coastal_races[index]
Hier zeigt sich die Grenze von „treu portieren“ ziemlich deutlich.
Wir portieren nicht das undefinierte Speicherverhalten. Wir portieren das konkrete Ergebnis der Referenz, gegen die dieses Projekt kompatibel sein soll.
Das ist in diesem Fall relevant: Die gewählte Rasse bestimmt, welche Männer in einer Region rekrutiert werden können. Eine andere Verteilung verändert das Spiel und außerdem den weiteren Zufallsstrom.
Felder als Notizzettel
Der zweite Pull Request portierte standard/map.cpp: Ebenen, Landverteilung, Terrain,
Namen, Rassen, Schächte und Tore.
Der alte Generator benutzt dabei einige Felder von ARegion als temporären
Arbeitsbereich. Während der Weltgenerierung bedeuten sie zeitweise etwas völlig anderes
als später im fertigen Spiel.
type kann etwa -1 für „noch unberührt“ oder R_NUM für „Land ohne zugewiesenes
Terrain“ enthalten.
wages ist vorübergehend gar kein Lohn, sondern der Index eines Namens im Namenspool.
-1 bedeutet noch unbenannt, -2 markiert eine gekappte Landbrücke.
GrowTerrain benutzt sogar race und population als Scratch-Felder:
// reference/standard/map.cpp
if (j==0) t->population = 0;
reg->population = 0;
reg->race = t->type;
reg->wages = t->wages;
Später räumt FinalSetup die Namen auf und Setup initialisiert den eigentlichen
Regionszustand.
Meistens jedenfalls.
Eine Ozeanregion hat keine Bevölkerung. Ihr Setup kehrt deshalb früh zurück, bevor eine echte Rasse gewählt wird.
Damit bleibt im Feld race der Terrain-Index stehen, den GrowTerrain dort
zwischengespeichert hat.
Für Ozean ist das der Wert 0.
Und Item 0 ist I_LEADERS, mit der Abkürzung LEAD.
Der Weltdump enthält deshalb bei jeder Ozeanregion im Feld race ausgerechnet den
Item-Index für Leader.
Das bedeutet natürlich nicht, dass im Ozean Anführer leben. Es ist ein übrig gebliebener Scratch-Wert in einem Feld, dessen spätere Bedeutung für eine unbewohnte Ozeanregion niemand braucht.
Im Speicherzustand ist er trotzdem vorhanden.
Der Port reproduziert ihn, denn das Done-Kriterium vergleicht den tatsächlichen Zustand der Welt und nicht nur die Teile, die ein Spieler normalerweise zu sehen bekommt.
Ein zweites Beispiel kommt aus derselben Ecke.
create_nexus_level nennt den Nexus zunächst Wyreth Nexus. Kurz darauf läuft
FinalSetup darüber. Weil sein temporäres wages noch -1 ist, wird die Region in
Unnamed umbenannt.
Deshalb heißt der Nexus in den Dumps dieser Serie Unnamed.
Die Sonde: Stufe für Stufe
Ein Vergleich ausschließlich am Ende der Weltgenerierung wäre für die Fehlersuche zu grob.
Bei ungefähr 190 Stellen mit Zufallsaufrufen könnte die erste falsche Ziehung weit vor dem ersten sichtbaren Unterschied liegen.
Deshalb prüft die Sonde die Generatoren zusätzlich in Etappen.
Dafür entsteht eine kleinere Welt mit fünf Ebenen, so wie CreateWorld sie mit
Unterwelt, Untertiefe und Abgrund aufbauen würde: 217 Regionen bei einer Oberfläche von
16 mal 16 und Seed 21.
Nach jeder erzeugten Ebene, nach den Schächten, nach den Ausgängen des Nexus und nach den
Toren schreibt die Sonde eine kompakte Skizze jeder Region: Typ, Namensindex,
Zwischenwerte in race und population, Tor und Nachbarn.
Am Ende folgen vollständige Regionszeilen und noch eine Zufallsziehung als zusätzlicher Kontrollwert.
Acht Zwischenstände.
Wenn der Python-Port abweicht, sieht man damit wenigstens, in welcher Phase der Unterschied erstmals auftaucht.
standard selbst besitzt weder Unterwelt noch Untertiefe noch Abgrund; die
entsprechenden Einstellungen sind dort 0. Die Generatoren dafür gehören trotzdem zu
CreateWorld und wurden mitportiert. Dieses künstliche Szenario ist derzeit der Ort, an
dem sie getestet werden.
Nicht portiert wurden zwei andere Pfade.
MakeIcosahedralRegions fehlt, weil keines der Regelwerke des Forks
ICOSAHEDRAL_WORLD aktiviert. Und die beiden Ring-Level werfen bereits in der
C++-Referenz eine Exception.
Der Python-Port antwortet auf diese Fälle mit NotImplementedError und dokumentiert,
warum.
Schon die Sonde selbst zeigte dabei, wie leicht man an solchen Datenstrukturen vorbeischreibt.
Ihr erster Build stürzte ab. create_levels reserviert in der Referenz das Array der
Level-Zeiger, initialisiert die einzelnen Einträge aber nicht. Die Dump-Funktion der
Sonde iterierte sofort über alle fünf Einträge, obwohl die Ebenen noch gar nicht erzeugt
waren.
Das war kein Fehler der Engine auf ihrem normalen Pfad, sondern ein Fehler unseres Testprogramms.
Nach der Korrektur stimmten alle acht Stufen, alle 217 vollständigen Regionszeilen und der nachfolgende Zufallswert im ersten Lauf.
Der Vollvergleich
Der dritte Pull Request verbindet die Teile: CreateWorld, NewGame, die beiden
Nichtspieler-Fraktionen The Guardsmen und Creatures, Stadtwachen, wandernde Monster
und Lair-Monster.
CreateWorld enthält in der Referenz noch Reste einer interaktiven Konfiguration:
// reference/standard/world.cpp
int xx = 64;
while (xx <= 0) {
logger::write("How wide should the map be? ");
...
}
xx startet mit 64.
Die Schleife läuft also nicht.
Für die Höhe gilt dasselbe. Auch ein mehrfeldriger Nexus wird von den Regelwerken des Forks nicht verwendet.
Die Fragen existieren im Quelltext, auf dem standard-Pfad werden sie aber nie gestellt.
Der Python-Port fragt deshalb ebenfalls nichts ab. Die Weltgröße von standard ist
64 mal 64; ein Multi-Hex-Nexus ist für diesen Port nicht implementiert.
Dann kam der Test, der Schritt 7 abschließt:
# tests/core/game/test_new_game.py
def test_new_game_reproduces_the_recorded_world(game: Game, world_full: dict[str, Any]) -> None:
assert new_game(game, world_full["seed"])
...
assert [faction_row(faction) for faction in game.factions] == world_full["factions"]
for region, expected in zip(game.regions, world_full["regions"], strict=True):
assert full_region_row(region) == expected, region.num
assert game.rng.get_random(RANDOM_RANGE) == world_full["next_random"]
Verglichen werden die Ebenen, sechs Zähler, beide Fraktionen und sämtliche 2.049 Regionen mit Produkten, Märkten, Objekten und Einheiten. Danach muss auch die nächste Zufallsziehung der Referenz entsprechen.
Der Test lief beim ersten vollständigen Versuch durch.
In etwa einer halben Sekunde.
Das kam nicht aus dem Nichts. Vorher war das Setup einzelner Regionen bereits auf 73 Regionen geprüft worden. Die Level-Generatoren mussten acht Zwischenstände einer Fünf-Ebenen-Welt reproduzieren. Der vollständige Vergleich war das Ende dieser Kette, nicht der erste Test der Weltgenerierung.
Zwei weitere Macken
Auch im letzten Teil des Generators steckt Verhalten, das man bei einer Neuimplementierung vermutlich anders schreiben würde.
CreateVMons, das die Monster der Schwarzen Feste erzeugt, holt die Monsterfraktion
nicht über das dafür vorgesehene Feld monfaction, sondern verwendet hart die
Fraktionsnummer 2.
Mit der Konfiguration von standard fällt das nicht auf. Würde man die
Stadtwachen-Fraktion abschalten, wäre die Monsterfraktion Nummer 1 und die Funktion
würde trotzdem nach Fraktion 2 suchen.
Der zweite Fall steckt in GrowWMons.
Die Welt wird dabei in Blöcke von 8 mal 16 Feldern aufgeteilt. Beim Überlauf in
X-Richtung prüft die Referenz mit > statt >=. An der Stelle, an der die Koordinate
um die Welt herum wieder an den Anfang gewrappt wird, kann dadurch dieselbe Spalte
doppelt in die Berechnung eingehen.
Auf dem einfeldrigen Nexus zählt derselbe Block sogar dieselbe Region zweimal.
Beide Effekte bleiben im Port erhalten und stehen in der Portierungskarte.
Was noch nicht portiert wurde
npc.cpp enthält außerdem MakeManUnit.
Die Funktion erzeugt Stadtwachen aus der lokalen Rasse statt aus Leadern und wird
gebraucht, wenn LEADERS_EXIST ausgeschaltet ist.
Für standard ist dieser Pfad nicht aktiv, deshalb ist MakeManUnit in diesem Schritt
noch nicht portiert. create_city_mon bricht auf diesem Pfad ausdrücklich mit
NotImplementedError ab.
Die Funktion kommt mit dem Regelwerk, das sie benötigt.
An dieser Stelle enthielt die Review-Nachricht zunächst einen falschen Satz: Kein
Regelwerk des Forks setze LEADERS_EXIST auf 0.
Das stimmte nicht.
kingdoms tut genau das.
Für standard änderte der Fehler nichts, aber er hätte fast den Weg in die
Portierungskarte gefunden. Ein Blick in alle rules.cpp hätte gereicht.
Die Stelle bleibt deshalb im Artikel. Eine Serie über das Arbeiten mit einem Agenten sollte auch zeigen, wann der Agent mit überzeugendem Ton eine überprüfbare falsche Behauptung aufstellt.
Was der Schritt gekostet hat
Vier Pull Requests haben unter src/ 5.128 Zeilen hinzugefügt.
Der größte Teil steckt in rulesets/standard/map.py mit 934 Zeilen,
world.py mit 511 und npc.py mit 367. names.py hat weitere 2.011 Zeilen – davon
sind 2.000 schlicht die Namen selbst. Dazu kamen 905 Zeilen in economy.py und
region.py sowie kleinere Module für das Ruleset-Interface und C-float-Arithmetik.
Die Modellsonde wuchs um 614 auf 2.534 Zeilen C++.
Ihre Fixtures liegen inzwischen bei 669 KB für die Szenarien, 661 KB für die vollständige Welt und 710 KB für den Weltdump aus Teil 7.
Interessanter als die Größen sind die Dinge, die während des Schritts sichtbar wurden.
Der Vergleich fand einen Portierungsfehler: Die erste Python-Version hatte Lairs vernünftig benannt.
Die Sonde selbst hatte zwei Fehler: SetTownType veränderte zunächst die falsche Kopie
einer handgebauten Region, und der Level-Dump griff auf noch nicht erzeugte Ebenen zu.
Dazu kamen mehrere Eigenheiten der Referenz: die Dummy-Lairs, der Zugriff vor
coastal_races, der übrig gebliebene LEADERS-Wert im race-Feld von Ozeanen, der
Unnamed-Nexus, die hart codierte Fraktion 2 und die doppelt gezählte Spalte bei
wandernden Monstern.
Das ist für mich der eigentliche Wert eines vollständigen Orakels.
Es zeigt nicht bloß, wo der Port falsch ist. Es zwingt dazu, genau festzuhalten, was die Referenz überhaupt tut.
Bei einer „sinngemäßen“ Neuimplementierung wären mehrere dieser Stellen wahrscheinlich unbemerkt geglättet worden.
Stolperfallen
- Der Scope steht im Aufrufpfad, nicht im Dateinamen.
mapgen.cpp,simplex.cppundnamegen.cppklingen nach diesem Schritt, liegen aber nicht auf dem Weg vonstandard::NewGame. Erst den Pfad lesen, dann den Port planen. - Ausnahmen schwächen das Orakel. Nicht jede Abweichung muss für immer erhalten
bleiben. Aber wenn man Felder wie den Ozean-
race-Wert einfach aus dem Vergleich entfernt, erfährt man auch nicht mehr, wann und warum sie sich ändern. - Zwischenstände machen Zufallsfehler auffindbar. Bei rund 190 Stellen mit Zufallsaufrufen ist ein Endvergleich zu grob. Acht Phasen der kleineren Welt grenzen die erste Abweichung deutlich besser ein.
- Undefiniertes Verhalten portiert man als beobachteten Effekt, nicht als
Speicherkunststück.
coastal_races[-1]ist kein Vertrag von C++. Relevant ist, was die gepinnte Referenz daraus macht. - Scratch-Felder können im Endzustand hängen bleiben. Das
race-Feld eines Ozeans enthält noch den Terrain-Index 0. Solche Werte wirken erst absurd, wenn ein vollständiger Dump sie sichtbar macht. - Prüfen, bevor man „kein Regelwerk“ schreibt. Die falsche Aussage zu
LEADERS_EXISTwäre mit einer Suche über die Rulesets sofort aufgefallen. - Ein Ruleset-Interface sollte schmal bleiben. Nicht jede Funktion in
standard/world.cppgehört insProtocol. Nur das, was die Engine vom Regelwerk verlangt, ist Teil des Vertrags.
Der Stand nach Teil 8
Schritt 7 ist abgeschlossen.
new_game(game, 20260822)
erzeugt im Python-Port denselben aufgezeichneten Weltzustand wie atlantis new in der
C++-Referenz mit demselben Seed: 2.049 Regionen, 115 Städte, 163 Lairs, 32 Tore und
403 Einheiten.
Die vollständigen Regionsdaten, Fraktionen und Zähler stimmen überein. Auch die nächste
Zufallsziehung nach NewGame ist gleich.
354 schnelle Tests laufen in rund sieben Sekunden. Elf Referenztests bauen die Sonde in
Docker und prüfen ihre Fixtures gegen einen frischen Referenz-Build. Beim dritten Pull
Request brauchte der CI-Job Reference 47 Sekunden.
Die Portierungsabdeckung steigt von 27,8 auf 34,9 Prozent: 16.723 von 47.949 Zeilen.
standard/map.cpp, standard/world.cpp und standard/monsters.cpp sind vollständig
portiert. aregion.cpp, economy.cpp, game.cpp und npc.cpp bleiben in progress:
Dort fehlen noch Datei-I/O, Berichte, Zugmethoden beziehungsweise MakeManUnit.
Eine offene Rechnung aus Teil 3 gibt es ebenfalls.
Damals war ein State-Dump geplant, den ein Konverter aus dem Speicherformat der C++-Referenz erzeugt. Er sollte ursprünglich in diesem Schritt entstehen.
Das ist nicht passiert.
Für das Done-Kriterium der Weltgenerierung reichte die vollständige Fixture der Sonde.
Der State-Dump wandert deshalb in Schritt 8, wo ohnehin der Reader für game.out
entsteht. Die Dokumentation des Vergleichs-Harnesses hält diese Verschiebung inzwischen
fest.
Und genau dort geht es weiter.
Die Welt existiert jetzt vollständig im Speicher eines Python-Prozesses. Als Nächstes muss sie in dasselbe Format geschrieben und wieder daraus gelesen werden können, das die C++-Engine seit Jahrzehnten benutzt:
game.out, rund 150.000 Zeilen positionale Werte ohne Feldnamen.
Schritt 8: Spielstand laden und speichern.
Erst treu lesen. Später verbessern.
Weiterlesen
- Atlantis PbeM neu geschrieben (Teil 7) – das Objektmodell, die Sonde und der erste Weltdump.
- Atlantis PbeM neu geschrieben (Teil 4) – der Zufallsgenerator, ohne den dieser Teil nicht funktionieren würde.
- Atlantis PbeM neu geschrieben (Teil 3) – das Harness und der Seed 20260822.
- Determinismus testen: Zufall, Zeit & I/O kontrollieren – dieselbe Idee im Kleinen, für eigene Projekte.
- Logging statt print – die Weltstatistiken der Referenz landen
im Port in einem
logging-Logger.
Sources: standard/world.cpp,
standard/map.cpp,
economy.cpp,
aregion.cpp,
object.cpp
und npc.cpp
des Forks,
typing.Protocol,
Python logging.
Tastenkürzel
- j k
- nach unten / oben scrollen
- n p
- nächster / vorheriger Abschnitt
- g G
- Anfang / Ende des Artikels
- t
- Gliederung zeigen
- ?
- diese Hilfe zeigen
- Esc
- schließen
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.