Zum Inhalt springen

cat posts/atlantis-pbem-neu-geschrieben-05-von-arrays-zu-datenmodellen.md

Atlantis PbeM neu geschrieben (Teil 5): Von globalen Arrays zu Datenmodellen

5.166 Zeilen C++-Tabellen, vier Enums, die nur per Position dazu passen, und ein Regelwerk, das alles in place umbaut. Teil 5 zeigt, wie die Spieldaten aus der Referenz gedumpt, generiert und gegen sie geprüft werden – und was eine -2 in den Fixtures verriet.

In Teil 4 bekam der Port seinen Zufallsgenerator und die String-Helfer: 637 Zeilen, 1,3 Prozent. Schritt 4 der Roadmap nimmt sich das nächste Fundament vor: die statischen Spieldaten. Gegenstände, Fertigkeiten, Gelände, Rassen, Gebäude und Zauber – alles, was in Atlantis PbeM schon definiert ist, bevor die erste Welt entsteht.

Die Roadmap verlangt dafür zweierlei. gamedata, gamedefs und die Konfiguration des standard-Regelwerks müssen als Python-Daten vorliegen. Gleichzeitig sollen die positionsgekoppelten Enums und Arrays durch Strukturen ersetzt werden, bei denen man erkennen kann, was ein Wert bedeutet.

Das Done-Kriterium bleibt so nüchtern wie beim Zufall: Ein Dump aller Tabellen muss den C++-Daten für standard entsprechen.

Dieser Schritt lässt den Port zum ersten Mal deutlich wachsen, von 1,3 auf 15,7 Prozent. Er ist auch der erste, bei dem der Agent große Teile des Codes nicht abschreibt, sondern generiert. Und er liefert einen Fund, den der grüne Vergleich selbst nicht finden konnte, weil Referenz-Fixture und Python-Port denselben Fehler enthielten.

Sechzehn Tabellen und vier Enums

gamedata.cpp hat 5.166 Zeilen und besteht zu einem großen Teil aus Datensätzen in geschweiften Klammern. Sechzehn globale std::vector halten die Spieldaten: ItemDefs mit 230 Gegenständen, SkillDefs mit 107 Fertigkeiten, ObjectDefs mit 81 Gebäude- und Schiffstypen und TerrainDefs mit 63 Geländearten.

Dazu kommen zwölf weitere Tabellen für Rassen, Monster, Waffen, Rüstungen, Reittiere, Kampfgegenstände, Heilung, Spezialangriffe, Effekte, Zauberreichweiten und Attributmodifikatoren.

Ein Eintrag aus ItemDefs sieht so aus:

// reference/gamedata.cpp
{"leader", "leaders", "LEAD",
 ItemType::NOTRANSPORT,
 NULL, 0, 0, 0, {{-1, 0}, {-1, 0}, {-1, 0}, {-1, 0}},
 NULL, 0, 0, {{-1, 0}, {-1, 0}, {-1, 0}, {-1, 0}},
 10, IT_MAN | IT_LEADER, 100, 1,
 15, 0, 0, 0, 2,
 -1, 0,
 -1, 0, 0,
 0, NULL, 0,
 "", { "", "", "", "" }, 0, 0},

Was die einzelnen Zahlen bedeuten, erkennt man hier nicht. Dafür braucht man den Kommentar oberhalb der Tabelle und die Klassendefinition in items.h. Selbst -1 bedeutet je nach Feld etwas anderes: mal „kein Verweis“, mal einen echten Zahlenwert mit besonderer Semantik.

Die Tabellen bilden die Vereinigung der Daten aller acht Regelwerke des Forks. Ein Regelwerk schnitzt daraus beim Start seine eigene Variante. standard tut das in ModifyTablesPerRuleset() mit 69 Aufrufen der Helfer aus modify.cpp: Einträge werden aktiviert, deaktiviert oder verändert – direkt in den globalen Tabellen.

Vier dieser Tabellen sind zusätzlich an anonyme Enums aus gamedata.h gekoppelt:

enum {
    I_LEADERS,
    I_VIKING,
    I_BARBARIAN,
    ...
    I_SILVER,

I_SILVER hat den Wert 19, weil Silber an Position 19 in ItemDefs steht, also der zwanzigste Eintrag ist. Diese Verbindung steht nirgendwo explizit. Wer im Enum oder in der Tabelle einen Eintrag an der falschen Stelle ergänzt, verschiebt die Bedeutung aller folgenden Indizes.

Die Architekturdokumentation des Forks warnt ausdrücklich vor dieser Positionskopplung. Der Compiler kann sie nicht prüfen.

Beim Lesen fiel noch etwas auf: Die Querverweise sind nicht einheitlich. Manche Tabellen verweisen per Index auf andere Tabellen – etwa Produktionszutaten, Rassen oder Geländeprodukte. Fertigkeiten, Spezialangriffe und Reichweiten werden dagegen häufig per String referenziert, beispielsweise pSkill = "MINI" oder special = "fireball".

Im gespeicherten Spielstand landen diese Tabellenindizes später nicht. Dort werden Abkürzungen und Namen geschrieben. Trotzdem ist die Tabellenreihenfolge Teil des Verhaltens. Schleifen über NITEMS bestimmen zum Beispiel, in welcher Reihenfolge Gegenstände in Berichten oder Märkten auftauchen.

Neben diesen Tabellen gibt es GameDefs, das große Konfigurationsobjekt eines Regelwerks. In der für dieses Projekt gepinnten Referenz umfasst es 167 Einstellungen, von MAX_SPEED bis NEW_SHIP_JOINS_FLEET_BEHAVIOR. standard/rules.cpp füllt sie für die Welt „Wyreth“.

Fünf Entscheidungen vor dem Code

Wie in den bisherigen Schritten begann auch dieser mit einem Vorschlag und offenen Fragen.

Wie ersetzen wir die vier positionsgekoppelten Enums? Der Vorschlag war ein IntEnum pro Tabelle. Die C++-Namen bleiben ohne Präfix erhalten, der Wert bleibt die Tabellenposition:

I_LEADERS wird ItemId.LEADERS, R_PLAIN wird TerrainId.PLAIN.

Damit bleibt jede C++-Aufrufstelle leicht wiederzufinden. Gleichzeitig kann pyright zwischen den verschiedenen Id-Typen unterscheiden. Ein zusätzlicher Test prüft, dass die generierten Enum-Werte weiterhin zu den Positionen der Tabellen passen – genau die Beziehung, die im C++-Code nur implizit existiert.

Nur Strings als Ids zu verwenden wäre lesbarer als rohe Zahlen, würde aber eine andere Eigenschaft verlieren: Ein Tippfehler wäre dann erst zur Laufzeit sichtbar.

Abschreiben oder generieren? 5.166 Zeilen Tabellen von Hand nach Python zu übertragen wäre wahrscheinlich die größte vermeidbare Fehlerquelle dieses Schritts gewesen. Stattdessen dumpt eine C++-Sonde die Daten als JSON. Ein Generator baut daraus Python-Module mit Dataclass-Literalen.

Die generierten Dateien werden eingecheckt. Ein Test führt den Generator erneut aus und schlägt an, sobald das Ergebnis vom Repository abweicht.

Wo leben die Tabellen? Nicht global. GameData hält alle sechzehn Tabellen. Jedes Spiel bekommt einen frischen Satz davon, auf den anschließend der Hook des Regelwerks angewendet wird. Die einzelnen Definitionen bleiben veränderbare Dataclasses, weil ModifyTablesPerRuleset() genau das tut: vorhandene Einträge in place ändern.

Was wird aus -1? Hier habe ich nachgefragt, weil wir dasselbe Problem schon beim Zustandsdump aus Teil 3 hatten.

Die Regel lautet: Ein negativer Wert, der einen fehlenden Verweis bedeutet, wird null. Ein negativer Wert, der selbst eine fachliche Bedeutung hat, bleibt eine Zahl.

NO_ITEM wird also null. Eine Marktmenge von -1, die „unbegrenzt“ bedeutet, bleibt -1.

Diese Unterscheidung gilt für alle Dumps. Das ist wichtiger als eine möglichst „pythonische“ Darstellung, denn bei einem späteren Vergleich soll null überall dasselbe bedeuten.

Braucht diese Architekturentscheidung ein eigenes Dokument? Der Agent empfahl zunächst nein. Das Verhalten der Engine ändert sich schließlich nicht.

Einen Pull Request später las er Entscheidung 0001 noch einmal und fand dort einen Satz, den er bei seiner Empfehlung übersehen hatte:

How the positional enums are replaced is Step 4's decision and gets its own record.

Damit war die Frage eigentlich längst entschieden. Also entstand 0003-positional-enums-become-generated-intenums.md, zunächst als proposed, dann von mir im Review angenommen.

Die Sonde: zweimal dumpen

Der erste Pull Request enthält wie schon in Teil 4 noch keinen Python-Port. Stattdessen kommt ein C++-Programm dazu.

tools/reference/gamedata_probe.cpp ersetzt beim Build main.cpp und wird gegen libengine.a sowie die bereits erzeugten Objektdateien des standard-Regelwerks gelinkt. Die Tabellen werden dafür nicht noch einmal separat kompiliert. Die Sonde sieht dieselben Daten wie das Referenz-Binary.

Sie läuft in zwei Modi.

gamedata_probe base schreibt die Tabellen so, wie gamedata.cpp sie definiert.

gamedata_probe standard ruft vorher ModifyTablesPerRuleset() auf und schreibt den Zustand danach.

Beide Ergebnisse landen als Fixtures im Repository: gamedata-base.json dient dem Python-Generator als Ausgangspunkt, gamedata-standard.json ist die Referenz für das Done-Kriterium.

Das Ausgabeformat änderte sich gleich beim ersten Versuch. nlohmann::json::dump(1) produzierte pro Fixture 489 KB und 33.676 Zeilen, praktisch jedes Feld in einer eigenen Zeile. Für einen Diff ist das eher lästig.

Die neue Fassung schreibt jeden Tabelleneintrag auf genau eine Zeile. Damit schrumpft eine Fixture auf 312 KB und 735 Zeilen. Ändert sich ein Datensatz, zeigt der Diff direkt diesen Datensatz.

Auch die 167 GameDefs-Felder der Sonde wurden nicht von Hand abgetippt. Ein Wegwerfskript las gamedefs.h und erzeugte die entsprechenden Zeilen.

Die Übersetzung eines Tabellenverweises nach JSON begann zunächst mit dieser kleinen Funktion:

// tools/reference/gamedata_probe.cpp
// A table index that means "no row" becomes null.
ordered ref(int index) {
    if (index < 0) return nullptr;
    return index;
}

Vier harmlose Zeilen.

Sie werden später noch wichtig.

Der Vergleich zwischen base und standard zeigt außerdem ziemlich genau, was das Regelwerk beim Start mit den gemeinsamen Tabellen macht: 46 Tabellenzeilen ändern sich.

24 der 25 aktivierten Gegenstände ändern ihren Status – der Speer war bereits aktiv. Dazu kommen zwei Fertigkeiten, 15 Gebäude und fünf Reichweiten, die bei EASIER_UNDERWORLD Ebenengrenzen überschreiten dürfen.

Eine Änderung betrifft kein Flag: ModifyItemProductionBooster(I_AXE, I_HAMMER, 1) trägt den Hammer als Produktionswerkzeug für die Axt ein.

Von den 69 Aufrufen in ModifyTablesPerRuleset() werden mit der normalen standard-Konfiguration 48 ausgeführt. Die übrigen 21 liegen hinter Schaltern, die für Wyreth deaktiviert sind.

Generieren statt abschreiben

Die Datentypen selbst sind von Hand geschrieben: pro *Type-Klasse aus den C++-Headern eine kw_only-Dataclass, mit den Feldern in derselben Reihenfolge. Flags werden IntFlag, kleine Enums IntEnum, Verweise bekommen den Typ ihrer jeweiligen Id.

Jedes Feld besitzt außerdem den leeren Wert der C++-Tabelle als Default. Dadurch muss ein generierter Datensatz nur die Werte nennen, die tatsächlich gesetzt sind.

Der Anführer von oben sieht in Python so aus:

# src/atlantis_ng/core/data/tables/items.py
# ItemId.LEADERS
ItemType(
    name="leader",
    plural="leaders",
    abbr="LEAD",
    flags=ItemFlag.NOTRANSPORT,
    weight=10,
    type=ItemCategory.MAN | ItemCategory.LEADER,
    base_price=100,
    combat=1,
    walk=15,
    speed=2,
),

Aus 34 positionsabhängigen Feldern im C++-Literal werden neun benannte Werte. Wer wissen will, was die 15 bedeutet, liest jetzt walk=15.

Der Generator tools/gamedata/generate.py hat gut 300 Zeilen. Er liest die vier Enums aus gamedata.h und erzeugt daraus ids.py. Anschließend lädt er jede Zeile der Fixture anhand der Typannotationen in die passende Dataclass und rendert das Objekt wieder als Python-Quelltext.

Default-Werte werden ausgelassen, Flags in ihre benannten Bits zerlegt und alle erzeugten Module anschließend durch ruff format geschickt.

Das Ergebnis sind 17 Module mit insgesamt 9.030 Zeilen Python für 5.166 Zeilen C++-Tabellen. Der Port ist an dieser Stelle also länger als das Original. Das ist aber kein handgeschriebener Boilerplate: Die zusätzlichen Zeilen machen aus Positionen benannte Felder und aus Zahlen benannte Werte.

Die erste Ausgabe des Generators war trotzdem falsch.

name, plural und abbr fehlten in jedem Literal. Um Default-Werte wegzulassen, verglich der Generator jedes Feld mit einer Default-Instanz. Diese Default-Instanz musste er für Pflichtfelder aber mit den Werten des aktuellen Objekts erzeugen.

Damit waren ausgerechnet die Pflichtfelder immer „gleich dem Default“ und verschwanden.

Der Fixture-Vergleich hätte den Fehler beim nächsten Schritt gefunden. Entdeckt wurde er früher, weil der Agent die erste generierte Datei tatsächlich gelesen hat.

Ein zweiter Stolperer war simpler: Die Sonde übernahm aus der Rüstungstabelle das Feld from. In C++ kein Problem, in Python ein Schlüsselwort. Das Feld heißt im Port deshalb save_from, passend zum vorhandenen Helfer modify_armor_save_from. Danach wurden beide Fixtures neu erzeugt.

Jetzt konnte der eigentliche Test laufen: GameData.base() erzeugen, mit dump() in dieselbe JSON-Struktur wie die Sonde bringen und gegen gamedata-base.json vergleichen.

Alle sechzehn Tabellen stimmten überein.

Was die Tabellen wissen

GameData enthält außerdem die Nachschlagefunktionen aus items.cpp, skills.cpp und object.cpp: find_item, lookup_skill, parse_object und ihre Verwandten.

Auch hier bleibt merkwürdiges Referenzverhalten zunächst erhalten.

parse_object liefert für ein Schiffs-Item -(item + 1), weil der Build-Befehl Schiffe und Gebäude im selben Zahlenraum unterscheiden muss. parse_item_category liefert das negierte IT_*-Bit und für das Wort item sogar -NITEMS.

Schön ist das nicht. Aber die späteren Aufrufer erwarten genau diese Werte.

Beim Schreiben der Tests lagen die Daten dreimal richtig und der Agent dreimal falsch.

find_item("XXXX") sollte nach seiner Erwartung nichts finden. Tatsächlich ist XXXX die Abkürzung des Amuletts der Unverwundbarkeit.

parse_giveable_item("SILV") sollte bei ausgeschlossenem NOMARKET kein Silber zurückgeben. Silber trägt aber tatsächlich dieses Flag und das Verhalten der Funktion ist entsprechend.

Und beim Langboot war die Annahme falsch, welcher Schiffstyp in den Basistabellen deaktiviert ist.

Drei Testerwartungen wurden geändert. Keine Zeile Portcode.

Das ist ein nützlicher Unterschied: Ein fehlschlagender Test sagt zunächst nur, dass Erwartung und Ergebnis nicht zusammenpassen. Er sagt noch nicht, welche Seite falsch ist.

Wood or stone

Der dritte Pull Request portiert modify.cpp. Beim Helfer ModifyObjectConstruction steht diese Zeile:

// reference/modify.cpp
if ((it < -1 && it != I_WOOD_OR_STONE) || it > (NITEMS -1)) return;

I_WOOD_OR_STONE ist -2.

Ein Gebäude mit diesem Wert wird nicht aus einem bestimmten Gegenstand gebaut, sondern aus Holz oder Stein – abhängig davon, was in der Region verfügbar ist. Elf Gebäude in gamedata.cpp benutzen diesen Sentinel, darunter Mine, Farm, Ranch, Timber Yard, Inn und Quarry.

Und jetzt zurück zu den vier Zeilen aus der Sonde:

if (index < 0) return nullptr;

Jeder negative Tabellenindex wurde zu null.

Auch -2.

Damit behaupteten beide C++-Fixtures bei diesen elf Gebäuden: kein Baumaterial.

Der Python-Generator übernahm genau diese Daten. Anschließend erzeugte GameData.base().dump() wieder dasselbe null. Der Vergleich war grün.

Das Done-Kriterium aus dem zweiten Pull Request konnte den Fehler gar nicht finden. Seine beiden Seiten stammten aus derselben bereits beschädigten Übersetzung.

Gefunden wurde die -2 erst beim Portieren des Codes, der diesen Wert tatsächlich verwendet.

Die Sonde behandelt I_WOOD_OR_STONE jetzt ausdrücklich als Ausnahme. -2 bleibt -2. Die Fixtures wurden neu erzeugt und der Python-Port gibt dem Wert einen Namen:

BuildMaterial.WOOD_OR_STONE

Das Feld hat entsprechend den Typ:

ItemId | BuildMaterial | None

Der Generator verarbeitet diese Union, indem er die möglichen Typen nacheinander probiert. ItemId(-2) scheitert, BuildMaterial(-2) passt.

Das ist für mich der wichtigste Fund dieses Schritts. Ein Vergleich kann sehr gut beweisen, dass zwei Darstellungen gleich sind. Er kann nicht beweisen, dass die gemeinsame Übersetzung davor korrekt war.

Oder kürzer: Wenn die Vorlage bereits Information verloren hat, kann die Kopie perfekt mit ihr übereinstimmen und trotzdem falsch sein.

GameDefs und der Hook

Der Rest des dritten Pull Requests ist vor allem systematische Portierungsarbeit.

GameDefs wird eine kw_only-Dataclass mit den 167 Feldern der gepinnten Referenz, jetzt in Python-Schreibweise:

defs.start_money statt Globals->START_MONEY.

Die Feldliste wurde wieder per Wegwerfskript aus gamedefs.h erzeugt, einschließlich der Kommentare, die im Header direkt am Feld stehen. Die anonymen Enums des C++-Headers bekommen Namen wie TaxFlag, ReportFlag und MultiplierUse.

rulesets/standard/rules.py liefert die Werte für Wyreth und die sieben Progressionstabellen.

modify.cpp enthält 85 Helfer – nicht 90, wie die Kick-off-Notiz ursprünglich geschätzt hatte. Im Python-Port werden daraus Funktionen, die ein GameData-Objekt verändern:

# src/atlantis_ng/core/data/modify.py
def enable_item(data: GameData, item: ItemId) -> None:
    """Clear ``ItemType::DISABLED``."""
    if not _valid(data.items, item):
        return
    data.items[item].flags &= ~ItemFlag.DISABLED

Auch die Guards und Sonderfälle der Referenz bleiben erhalten.

ModifySkillCost(-1) lehnt den Wert ab, während ModifyItemWeight(-5) das Gewicht auf 0 klemmt. ModifyTerrainRace leert einen Slot, wenn das übergebene Item kein Mensch ist. ModifySpecialShields akzeptiert sogar NUM_ATTACK_TYPES als Typ, weil die Referenz diesen Wert für „nicht abwehrbar“ verwendet.

Solche Unterschiede wirken inkonsistent. Für diesen Port sind sie trotzdem zunächst Verhalten. Zehn Tests halten sie fest, damit sie später nicht versehentlich im Namen der Sauberkeit vereinheitlicht werden.

Der Hook rulesets/standard/hooks.py::modify_tables(data, defs) bildet ModifyTablesPerRuleset() in derselben Reihenfolge nach.

Am Ende steht wieder ein Vergleich gegen die Referenz:

# tests/rulesets/standard/test_standard_data.py
@pytest.mark.parametrize("table", TABLES)
def test_the_standard_tables_are_the_reference_dump(table: str, reference: dict[str, object]) -> None:
    """Done when a dump of all tables matches the C++ data for `standard` (docs/roadmap.md, Step 4)."""
    assert standard.game_data().dump()[table] == reference[table]

Alle sechzehn Tabellen stimmen überein. GameDefs stimmt Feld für Feld, ebenso die Progressionen.

Zusätzliche Tests schalten bewusst Optionen um, die Wyreth normalerweise deaktiviert hat. So wird beispielsweise geprüft, dass MANIPULATE bei aktivierten Apprentices und GEMCUTTING bei zusätzlichen Underworld-Ebenen tatsächlich freigeschaltet werden.

Damit ist Schritt 4 abgeschlossen.

Stolperfallen

  • Ein grüner Vergleich beweist nur Gleichheit. Die Sonde verwandelte -2 in null, der Generator übernahm es, und der Python-Dump sah anschließend exakt genauso aus. Korrekt war trotzdem keine der beiden Seiten.
  • Generierter Code muss jemand lesen. Beim ersten Lauf fehlten name, plural und abbr überall. Ein späterer Test hätte das gefunden, ein Blick auf die erste erzeugte Datei fand es früher.
  • Alte Entscheidungen gehören zum Kontext. Der Agent empfahl zunächst kein ADR für die Enums, obwohl 0001 genau dieses ADR bereits verlangt hatte. Das Problem war nicht die Begründung, sondern dass die ältere Entscheidung bei der Empfehlung nicht berücksichtigt worden war.
  • Sentinel-Werte verdienen Namen. BuildMaterial.WOOD_OR_STONE erklärt mehr als -2, und ItemId | BuildMaterial | None macht die drei möglichen Fälle auch für pyright sichtbar.
  • Namen aus C++ sind nicht automatisch gültige Python-Namen. from war der einzige Treffer bei dieser Portierung. Ein Generator sollte solche Fälle mit keyword.iskeyword erkennen.
  • Ein roter Test verurteilt nicht automatisch den Port. Bei XXXX, Silber und dem Langboot war jeweils die Testerwartung falsch. Erst die Referenzdaten entschieden die Frage.
  • Zählen ist besser als schätzen. Aus den 90 Helfern der Kick-off-Notiz wurden nach dem tatsächlichen Zählen 85. Die fünf machen für das Projekt kaum einen Unterschied; die Gewohnheit, Zahlen zu messen, schon.

Der Stand nach Teil 5

Drei Pull Requests, Schritt 4 abgeschlossen.

core/data/ enthält die sechzehn Tabellen als generierte Module, ihre Dataclasses, die vier Id-Typen, die Lookup-Funktionen und die 85 Helfer aus modify.cpp. core/game_defs.py enthält GameDefs; unter rulesets/standard/ liegen die Werte für Wyreth und der Hook.

Zwei Fixtures aus der C++-Referenz halten fest, wie die Tabellen vor und nach ModifyTablesPerRuleset() aussehen. Die Tests vergleichen den Python-Port mit beiden.

Die Portierungsabdeckung steht bei 7.537 von 47.949 Zeilen, 15,7 Prozent. 199 schnelle Tests laufen in 2,5 Sekunden. Ein spürbarer Teil davon steckt im Generator-Test, der siebzehnmal ruff format aufruft.

Die Vergleichsquote bleibt bei 100 Prozent von drei Szenarien, weiterhin Referenz gegen Referenz. Eine spielbare Python-Engine gibt es noch nicht.

In Teil 6 entsteht dafür zum ersten Mal ein großes sichtbares Ergebnis: das Regelbuch. genrules liest genau die Tabellen und GameDefs, die jetzt portiert sind, und erzeugt daraus die HTML-Regeln für das Regelwerk.

Das Done-Kriterium: Das Python-Regelbuch muss inhaltlich dem der C++-Referenz entsprechen.

Weiterlesen

Sources: gamedata.h, gamedata.cpp, modify.cpp, standard/extra.cpp und docs/architecture.md des Forks, Python enum, Python dataclasses, nlohmann/json.

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!