Die verbindliche Beschreibung der Schnittstelle zwischen Spielserver, Backend und Browser.
Diese Datei ist normativ für die Lua-Seite. Die Luanti-Mod kann die TypeScript-Schemas aus packages/protocol nicht importieren; für sie gilt, was hier steht. Ein Test in packages/protocol/src/protocol.test.ts stellt sicher, dass hier kein Kommando und kein Ereignis fehlt.
Der Spielserver pollt. Luanti hat keine WebSockets, nur minetest.request_http_api(). Es gibt also keinen Weg, aktiv etwas an den Spielserver zu schicken. Der Browser darf WebSockets nutzen, beide sprechen dieselben Nachrichten.
Ein Poll pro Server, nicht pro Turtle. Bei 25 Turtles in einer Klasse wären 25 offene Anfragen nicht tragbar. Die Mod registriert sich einmal und holt Kommandos für alle ihre Turtles gebündelt ab.
Übertragen wird Lua, keine Blockstruktur. Luanti und CC:Tweaked bekommen exakt dieselbe Datei. Alles andere bedeutete zwei Codegeneratoren, die auseinanderdriften.
Turtle-Kennungen sind nur je Server eindeutig. Erst das Paar (serverId, turtleId) ist global eindeutig; das Backend darf nie allein anhand einer turtleId nachschlagen.
| Feld | Form |
|---|---|
serverId, turtleId, programId |
Zeichenkette, 1–64 Zeichen |
code |
genau 5 Buchstaben aus A–Y |
playerName |
Luanti-Spielername, 1–64 Zeichen |
seq |
ganze Zahl ≥ 0 |
Codes werden als Buchstaben übertragen und gespeichert, nie als Emoji-Zeichen: Buchstaben überleben jede Unicode-Normalisierung und jedes Encoding unverändert. Die Umwandlung in Emojis passiert erst bei der Anzeige — 😀=A, 🐢=B, … 🎈=Y (siehe packages/emoji).
Der playerName ist keine Identität. Im Klassenzimmer melden sich Kinder mit frei gewählten Namen an und wechseln sie ständig. Er dient nur dazu, innerhalb einer Sitzung weitere Turtles ohne erneutes Koppeln zuzuordnen.
Eine Sitzung ist ein Kind, keine Turtle und kein Gerät. Ihr Schlüssel ist der code — derselbe, den das Kind einmal von der Fernbedienung abgetippt hat.
Der Code hat zwei Leben. Beim ersten Mal stammt er aus einem pair_requested-Ereignis und gilt zehn Minuten; das Einlösen macht daraus eine dauerhafte Sitzung. Danach ist er die Anmeldung des Kindes — am nächsten Tag, an einem anderen Rechner, auch wenn gerade kein Luanti läuft. Sonst wären die gespeicherten Programme mit dem Browser-Tab verloren.
Daraus folgen zwei Dinge:
- Ein einmal vergebener Code wird nie wieder ausgegeben. Sonst käme das nächste Kind in die Programme des ersten.
- Programme hängen an der Sitzung, nicht an der Turtle. Turtles werden neu gesetzt, Kinder bleiben.
Der Code ist damit zugleich das Geheimnis, und fünf Buchstaben aus 25 sind ein schwaches: 25⁵ ≈ 9,7 Mio. Das ist eine bewusste Abwägung zugunsten von Kindern, die einen langen Token weder abtippen noch aufheben können. Was dagegen schützt, ist die Drossel (apps/server/src/drossel.ts), nicht die Länge — siehe „Grenzen" am Ende.
Alle Pfade unter /api/v1. Zwei Aufrufer, zwei Ausweise:
| Aufrufer | Endpunkte | Authorization |
|---|---|---|
| Spielserver | /servers/register, /turtles/…, /poll, /events |
Bearer <token> aus der Registrierung |
| Browser | /session, /session/…, /programs, /programs/… |
Bearer <code> des Kindes |
| — | /pair |
keiner; das ist die Anmeldung selbst |
Einmalig beim ersten Start. Das Ergebnis gehört in mod_storage.
-> { "name": "Klassenserver 7b", "gameId": "minetest_game", "modVersion": "0.1.0" }
<- { "serverId": "...", "token": "..." }
Turtle wurde gesetzt oder nach einem Serverstart wiedergefunden.
-> { "turtleId": "...", "playerName": "...", "label": "...",
"position": {"x":0,"y":0,"z":0}, "facing": "north" }
<- { "known": true, "studentCode": "BHFAT" }
studentCode fehlt, solange die Turtle keiner Sitzung zugeordnet ist.
Holt Kommandos für alle Turtles dieses Servers.
since ist die höchste bereits verarbeitete Nummer und dient zugleich als Bestätigung. Das Backend schickt alles darüber erneut, solange es unbestätigt ist — damit übersteht die Zustellung Verbindungsabbrüche.
waitSeconds ist eine Bitte, keine Zusage. Das Backend antwortet spätestens nach dieser Zeit, notfalls mit leerer Liste. Standardwert: 25 Sekunden.
Die Mod muss timeout in jeder Anfrage selbst setzen — empfohlen 35 Sekunden. Luantis Standard-curl_timeout liegt bei 20 000 ms und würde einen 25-Sekunden-Long-Poll abschneiden. Gemessen (siehe spikes/longpoll/ERGEBNIS.md): req.timeout überschreibt curl_timeout zuverlässig, Antworten nach 45 Sekunden kommen an. Damit muss ein Serverbetreiber an seiner minetest.conf nichts ändern außer secure.http_mods.
<- { "commands": [ ... ], "seq": 42 }
Gebündelte Ereignisse der letzten Runde, höchstens 200 auf einmal.
-> { "events": [ ... ] }
<- { "accepted": 7 }
Anmeldung aus dem Browser, der einzige Endpunkt ohne Token. Löst entweder einen schwebenden Kopplungscode ein oder meldet eine bestehende Sitzung wieder an — der Aufruf ist derselbe.
-> { "code": "BHFAT" }
<- { "studentCode": "BHFAT", "turtles": [ { "serverId": "...", "turtleId": "...", "playerName": "...", "position": {...}, "facing": "north" } ] }
turtles ist leer, wenn gerade kein Spielserver läuft. Die Anmeldung gelingt trotzdem — das Kind kommt an seine Programme.
Unbekannter oder abgelaufener Code: 404. Zu viele Fehlversuche: 429 mit Retry-After.
Der aktuelle Stand der Sitzung, für den Wiedereinstieg nach einem Neuladen.
<- { "studentCode": "BHFAT", "turtles": [ … ] }
Der Browser reiht ein Kommando für eine seiner Turtles ein. seq und programId vergibt das Backend.
-> { "turtleId": "...", "type": "load_program", "name": "Treppe", "source": "turtle.forward()" }
<- { "seq": 7, "programId": "..." }
Erlaubt sind load_program, run, pause, stop, step und manual. Nicht show_pair_code — sonst könnte ein Fremder einen beliebigen Code auf einer Fernbedienung erscheinen lassen.
Gehört die turtleId nicht zu dieser Sitzung: 404.
Der Lesekanal des Browsers. Dasselbe since-Muster wie beim Poll, aber ohne Long-Poll: den Browser kostet eine offene Verbindung mehr als den Spielserver, und er darf einfach alle paar Sekunden erneut fragen.
<- { "events": [ { "seq": 3, "serverId": "...", "event": { "type": "output", "turtleId": "...", "text": "Hallo" } } ], "seq": 3 }
Eine Sitzung hebt die letzten 200 Ereignisse auf. Wer länger weg war, verliert die ältesten — gewollt: wer aus der Pause zurückkommt, will den aktuellen Stand, nicht das Protokoll der letzten Stunde.
Die Programme der Sitzung. Die Liste führt bewusst weder workspaceXml noch source mit — bei einer Handvoll Programmen à 500 kB wäre sie sonst megabyteschwer.
GET /programs <- { "programs": [ { "id": "...", "name": "Treppe", "updatedAt": "..." } ] }
POST /programs -> { "id": "...", "name": "Treppe", "workspaceXml": "<xml/>", "source": "..." }
<- { "id": "...", "name": "Treppe", "workspaceXml": "<xml/>", "source": "...", "updatedAt": "..." }
GET /programs/<id> <- das ganze Programm
id beim Speichern ist optional. Fehlt sie oder ist sie in dieser Sitzung unbekannt, entsteht ein neues Programm — eine geratene fremde id überschreibt also nichts.
Jedes Kommando trägt seq und turtleId.
type |
Weitere Felder | Bedeutung |
|---|---|---|
load_program |
programId, name, source |
Lua auf die Turtle laden, nicht starten |
run |
— | Geladenes Programm starten oder fortsetzen |
pause |
— | Anhalten, Zustand behalten |
stop |
— | Abbrechen, Zustand verwerfen |
step |
— | Genau eine Aktion ausführen, dann anhalten |
manual |
action |
Direktsteuerung über die Fernbedienung |
show_pair_code |
code, expiresInSeconds |
Diesen Code auf der Fernbedienung anzeigen |
manual wird verworfen, solange ein Programm läuft — sonst streiten sich Kind und Programm um dieselbe Turtle.
Der Kopplungscode entsteht im Backend, nicht in der Mod. Die Mod meldet nur pair_requested; welcher Code angezeigt wird, entscheidet das Backend und schickt ihn als show_pair_code zurück. Nur so ist garantiert, dass der Code gerade nicht anderweitig vergeben ist — bei einer zufälligen Kollision würde sonst ein Kind an eine fremde Turtle gekoppelt.
Jedes Ereignis trägt turtleId.
type |
Weitere Felder | Bedeutung |
|---|---|---|
pair_requested |
playerName |
„Editor öffnen" gedrückt, noch keine Kopplung |
paired |
studentCode |
Kopplung steht |
status |
state, programId? |
idle / running / paused / error |
position |
position, facing |
Turtle hat sich bewegt |
output |
text |
Ausgabe aus print() |
error |
message, blockId? |
Programm abgebrochen |
finished |
— | Programm durchgelaufen |
blockId stammt aus den Kommentaren, die der Generator ins Lua schreibt (--[[cc:aX7f]]). Damit lässt sich ein Fehler im Editor am richtigen Block anzeigen, statt nur eine Zeilennummer zu melden.
Namen und Rückgabewerte sind 1:1 zu CC:Tweaked kompatibel. Alle geben true/false zurück.
Bewegung forward back up down turnLeft turnRight
Aktion dig digUp digDown place placeUp placeDown
Sensorik detect detectUp detectDown
Bewegungen scheitern an Hindernissen, Graben und Setzen an Schutzgebieten und leerem Inventar.
Da Phase 1 kein turtle.select() kennt, nimmt place() den ersten nicht-leeren Inventarslot.
Kind Fernbedienung Backend Browser
| | | |
| "Editor öffnen" | | |
|---------------------->| | |
| | pair_requested | |
| |----------------->| |
| | show_pair_code | |
| |<-----------------| |
| 5 Emojis | | |
|<----------------------| | |
| | |
| tippt die Emojis nach ------------------>| POST /pair |
| |------------------->|
| | paired | Turtle erscheint |
| |<-----------------| |
Weitere Turtles derselben Luanti-Anmeldung landen ohne erneutes Koppeln im offenen Tab: announce liefert dann bereits einen studentCode.
Der Code ist Anmeldung und Geheimnis zugleich, und er ist kurz. Was das konkret heißt:
Die Drossel arbeitet zweistufig. Pro Absender: ab dem sechsten Fehlversuch 1 s Sperre, mit jedem weiteren verdoppelt, gedeckelt bei 5 min. Über alle Absender zusammen: ein gemeinsamer Eimer von 60 Versuchen, der mit 10/s nachläuft. Nur Fehlversuche zehren daran.
Die zweite Stufe ist die eigentliche Grenze. Die Sperre pro Absender allein hilft nur gegen einen einzelnen Angreifer: 25⁵ Codes bei 5 min sind für eine Adresse 93 Jahre, für tausend parallel noch 34 Tage. Der gemeinsame Eimer gilt dagegen unabhängig von der Zahl der Adressen.
Was bleibt. Gegen einen bestimmten Code reicht das bequem. Gegen irgendeinen bei vielen aktiven Sitzungen wird es dünn — bei 500 Kindern genügen im Mittel rund 20 000 Versuche, bei 10/s also knapp eine halbe Stunde. Wer das schließen will, hat zwei billige Hebel: einen Buchstaben mehr (25⁶ ≈ 244 Mio., für das Kind ein Emoji mehr) oder Sitzungen nach einigen Wochen Ruhe verfallen lassen, damit die Zahl gültiger Codes nicht unbegrenzt wächst.
Was nicht geschützt ist. Wer den Code eines Kindes sieht — über die Schulter, im Chat, auf dem Beamer — hat vollen Zugriff auf dessen Programme. Das ist hingenommen: die Programme sind Unterrichtsmaterial, kein Geheimnis, und die Alternative wäre eine Anmeldung, die Grundschulkinder nicht bedienen können.
Solange kein Reverse Proxy geklärt ist, zählt die Drossel auf remoteAddress. Steht später ein Proxy davor, teilen sich alle Kinder einen Zähler, bis der passende Header ausgewertet wird — siehe TODO.md, Deployment.