173 lines
9.2 KiB
Markdown
173 lines
9.2 KiB
Markdown
# 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).
|