# 13 — Script-Engine wählen und Sandbox härten Type: grilling Status: resolved Blocked by: 01 ## Question Rhai oder mlua — und wie wird die gewählte Engine abgedichtet? Ticket 05 hat die Fakten geliefert und Rhai empfohlen. Die Entscheidung steht noch aus, und sie hängt bewusst an 01: Reden Scripts ausschließlich über das Effekt-Vokabular, ist die Wahl später umkehrbar; reichen sie tiefer in den Game-State, ist sie es nicht. Zu klären: - **Ist das Effekt-Vokabular die einzige Schnittstelle für Scripts?** Wenn ja, ist die Engine-Wahl eine Implementierungsentscheidung statt einer Architekturentscheidung. - **Zielgruppe**: „gut moddbar für einen kleinen Kreis" spricht für Rhai, „möglichst viele fremde Mods" für Lua. Das ist die Frage, an der die Empfehlung kippt. - **Vertraust du fremden Mods?** Bei feindlichen Mods ist Luau der einzige echte Sandbox — zum Preis einer C++17-Toolchain und eines Lua-*Dialekts*. - **Härtungs-Checkliste** für die gewählte Engine festlegen. Bei Rhai mindestens: `FileModuleResolver` abschalten, `eval()` deaktivieren, CPU- und Memory-Limits setzen. Bei mlua: niemals `Lua::new()`, sondern `new_with` mit ausgewählten Libs, `load`/`loadstring`/`dofile`/`require` entfernen, `ChunkMode::Text` erzwingen. - **Wie geht das Projekt mit den Rhai-Risiken um** — Bus-Faktor 1, Unsoundness-Issue #894 seit 2024, `smartstring` unmaintained (RUSTSEC-2026-0249)? Akzeptiert, beobachtet, oder Ausschlusskriterium? - **Wann fällt die Entscheidung endgültig** — jetzt, oder erst wenn Mod-Stufe (c) gebaut wird? Ergebnis: gewählte Engine mit Begründung, Härtungs-Checkliste, und die Regel, was Scripts sehen dürfen. ## Answer Begriffe im [Glossar](../../../CONTEXT.md). ### Die Wahl ist eine Implementierungsentscheidung — deshalb fällt sie leicht Scripts lesen über **eine Handvoll Funktionen** und schreiben ausschließlich über **Aktionen**. Weder lesen sie direkt im `Game`-Baum noch halten sie Zustand außerhalb des Vokabulars. Damit ist die Schnittstelle klein genug, um sie in jeder Engine nachzubauen — genau die Leitplanke aus Ticket 05. Ein Engine-Wechsel kostet die Bindungsschicht, nicht die Architektur. Die strikte Variante — Script bekommt Daten, gibt Aktionen zurück, liest gar nichts — wäre sauberer gewesen und macht Scripts sinnlos: Was ein Script gegenüber einem Trigger *hinzufügt*, ist Rechnen („Schaden skaliert mit der Zahl der Untoten im Raum"), und dafür muss es lesen können. ### Rhai — unbequem begründet **Für ein unveröffentlichtes Terminal-Roguelike schreibt niemand Mods.** Lua gewinnt bei der Vertrautheit deutlich (Factorio, GMod, WoW, Noita, Don't Starve), aber das zahlt sich erst aus, wenn es eine Modder-Gemeinde gibt — und die entsteht nicht durch die Sprachwahl, sondern durch ein Spiel, das Leute spielen wollen. Bis dahin ist der Modder, den es zu bedienen gilt, der Autor selbst, und die Charter nennt „Rust lernen" als halbe Motivation. Dazu der schärfste Einzelbefund aus 05: Ein Lua-Script hat unter `StdLib::ALL_SAFE` per `io.open` eine echte Datei gelesen — „safe" heißt dort speichersicher, nicht fähigkeitsbeschränkt. Rhai hat Datei-, Netz- und Prozesszugriff gar nicht erst in der Standard-Engine. **Mods gelten als vertrauenswürdig.** Kein Spiel dieser Größenordnung behandelt sie anders — Factorio, Minecraft, Skyrim und Noita vertrauen alle. Der Spieler entpackt einen Ordner in sein Profil, dieselbe Vertrauensentscheidung wie bei jeder heruntergeladenen Software; eine Sandbox schützt ihn nicht davor, sondern verschiebt nur, wo er sie trifft. Damit scheidet Luau samt C++17-Toolchain und Lua-*Dialekt* aus — der Dialekt, den die vertrauten Lua-Modder gerade nicht können. ### Härtung — gegen Unfälle, nicht gegen Absicht ```rust engine.set_module_resolver(DummyModuleResolver::new()); // kein ./*.rhai von der Platte engine.disable_symbol("eval"); // kein Code aus Strings engine.set_max_operations(100_000); // CPU pro Aufruf engine.set_max_call_levels(32); // Rekursion engine.set_max_string_size(8_000); engine.set_max_array_size(1_000); engine.set_max_map_size(1_000); engine.set_max_expr_depths(64, 32); // Parser-Tiefe ``` Alle Methodennamen gegen die Rhai-Doku geprüft. `on_progress` bleibt weg — es ist der Abbruchhaken für lang laufende Scripts, und `set_max_operations` deckt denselben Fall mit einem Wert statt mit einer Callback-Schleife ab. **`set_max_operations` gehört ausdrücklich *nicht* in `[balance]`** aus Ticket 14: Ein Mod, der sein eigenes CPU-Limit hochsetzen kann, invertiert die Härtung. Ein Script, das an ein Limit stößt, ist ein **Eintrags-Fehler** nach der Regel aus 04: Warnzeile mit Mod-ID, Script-Name und Zeile, Trigger verworfen, Spiel läuft weiter. Rhais Fehlermeldungen mit Zeile *und* Spalte sind laut 05 genau dafür da. ### Die Schnittstelle — was Scripts sehen dürfen ``` attribute(wer, name) -> f64 has_tag(wer, tag) -> bool health(wer) -> f64 has_item(wer, id) -> i64 stamina(wer) -> f64 floor() -> i64 rand() -> f64 ``` Alle Leseoperationen sind an der Bedingungsgrammatik aus 08 gespiegelt — **Scripts sehen nichts, was eine Bedingung nicht auch sähe**, sie können nur damit rechnen. Das ist die Regel; die Liste selbst wird endgültig, wenn Stufe (c) gebaut wird. Bewusst gestrichen: `turn()` und `enemy_count()`. Für beide gibt es kein benanntes Beispiel, und `enemy_count()` deckt sein eigenes Motivbeispiel („Schaden skaliert mit der Zahl der Untoten im Raum") nicht einmal ab — dafür bräuchte es Iteration. Die Umkehrbarkeit aus Q1 hängt daran, dass diese Schnittstelle klein bleibt; sie auf Vorrat wachsen zu lassen, während die Stufe verschoben ist, wäre das Gegenteil. `health` und `stamina` sind **eigene** Funktionen statt `attribute("health")`, weil Ticket 01 scharf trennt: `max_health` ist ein Attribut, der Lebensstand ist Zustand. Diese Trennung darf im Script-API nicht verwaschen. **`rand()` läuft durch den Generator aus `Game`**, und Rhais eigenes `rand`-Paket wird nicht registriert. Sonst gilt „Nachladen würfelt nicht neu" aus Ticket 14 für alles außer für Scripts — und ausgerechnet die interessanten Effekte würfelten beim Laden neu. Geschrieben wird ausschließlich über Aktionen: Ein Script **gibt Aktionen zurück**, die in die Warteschlange aus 08 wandern. Damit deckelt der 100-Events-Zähler auch ein Script, das sich selbst aufruft. ### Scripts sind die elfte Registry ```toml { action = "run_script", script = "meinmod:feuerregen" } ``` `run_script` als **neunte Aktion**. Dadurch entstehen keine neuen Platzierungsregeln: Ein Script ist überall verwendbar, wo eine Aktion stehen darf — an einem Gegenstand, einer Fähigkeit, einer Questbelohnung, einem `ai`-Eintrag, einem Raum-Trigger. Dieselbe Bewegung wie `core:attack` in Ticket 11 und `flee` in 14: Was wie Engine-Logik aussieht, ist Inhalt. Die Alternative — Scripts registrieren sich selbst auf Events — wäre genau die Variante, bei der ein Script Zustand außerhalb des Vokabulars hält, und die macht die Engine-Wahl unumkehrbar. ### Risiken: beobachtet, mit benanntem Ausstiegssignal „Beobachten" ohne Kriterium ist ein Wort ohne Folge, deshalb ausgeschrieben: | Signal | Reaktion | |---|---| | Keine Rhai-Release in zwölf Monaten | wechseln — Stand 1.25.1 vom 29.05.2026, frisch, 3,4 Mio. Downloads in 90 Tagen | | Unsoundness in einem Pfad, den wir tatsächlich nutzen | wechseln — Issue #894 ist das bisher nicht | | `smartstring` unmaintained (RUSTSEC-2026-0249) | **kein** Ausstiegsgrund | Zu `smartstring`: Das Repo wurde am 03.05.2026 archiviert, die Advisory kam am 10.08.2026 und ist ein INFO-Eintrag über fehlende Wartung, keine Verwundbarkeit (genannte Alternativen: `compact_str`, `smol_str`). Rhai 1.25.1 erschien danach ohne Wechsel. `cargo audit` wird das melden, und das ist die angemessene Menge Ärger dafür. ### Stufe (c) wandert hinter 1.0 Die **Entscheidung** steht jetzt, die **Umsetzung** nicht. Stufen (a) und (b) sind durch 01–03 vollständig beschrieben und liefern bereits zehn Registries, Trigger, Bedingungen, Affixe und `[balance]` — sehr viel Moddbarkeit ohne eine Zeile Script. Stufe (c) ist die einzige, die eine neue Dependency, eine Bindungsschicht und eine Härtungsliste braucht. Ticket 02 hat die Regel selbst formuliert: „Braucht ein Gegenstand etwas, das Modifikator, Aktion oder Fähigkeit nicht ausdrücken können, ist das eine Lücke im Vokabular." Eine Script-Engine einzubauen, *bevor* jemand an diese Lücke gestoßen ist, baut die Antwort vor der Frage — und jede Lücke, die ein Script stopfen würde, ist zuerst ein Hinweis, dass das Vokabular sie stopfen sollte. Die Entscheidung jetzt zu treffen kostet dagegen nichts und legt fest, **wogegen** das Vokabular gebaut wird. Genau das ist die Leitplanke aus 05. **Folge: Die Destination der Map ändert sich** — 1.0 liefert Mod-Stufen (a) und (b), Stufe (c) kommt danach. ### Nachtrag an Ticket 01 `run_script` als **neunte Aktion**. Der Katalog hat damit: `damage`, `change_attribute`, `apply_modifier`, `remove_modifier`, `give_item`, `remove_item`, `start_quest` (08), `flee` (14), `run_script` (13).