Start: der Code

Wie es läuft

Ablaufdiagramme der Teile, die kein Standard-Django sind: was mit wem redet, in welcher Reihenfolge, und wo man im Code anfängt zu lesen. Anmelden, Registrieren, Passwort zurücksetzen und der Admin fehlen — das macht Django sowieso.

Jede Box nennt die Datei oder Funktion, für die sie steht. Die Diagramme sind Mermaid und stehen auch im Repository, in docs/de-flowcharts.md.

Die Teile

Eine Django-App auf Daphne liefert alles aus, was nicht die SPA ist: die REST-API, die Websockets und die serverseitig gerenderten Seiten. Die Simulation läuft nie in einer Anfrage — sie läuft auf einem Celery-Worker, und ihre Ergebnisse kommen über den Websocket bei den Browsern an.

CeleryDaphne — backend/Browser/api/…/ws/…eine Runde ist komplettTasksEventsplant eingroup_sendSPA unter /app/frontend/ — Spiel, Seitender Spielleitung, EditorDjango-SeitenStartseite, /docs,Rechtliches, Anmeldungnginxliefert das SPA-Bundle aus,reicht alles andere weiterWebsocketsgame/consumers.pyREST-APIgame/, maps/Templatesbackend/template/RedisChannel Layer, Cache,Celery-BrokerBeatanonymisieren stündlich,ruhende Spiele 02:30,alte Sitzungen 03:00Workersimuliert eine Rundegame/tasks.pyPostgresSpiele, Karten, Ergebnisse

Ein Spiel von Anfang bis Ende

Ein Spiel ist eine GameSession, eine Runde eine GameRound. Die Phasen zwischen den Runden haben weiter unten ein eigenes Diagramm. Jedes Ende schreibt end_reason in dem Moment, in dem es passiert: co2_limit, max_rounds, host oder idle.

Spielleitung legtdas Spiel anBeitritteSpielleitung startet,Runde 1 auf derBasisversionder letzte Zug ist daround.completedCO2-Budget verbrauchtoder letzte Runde gespieltnächste Runde, auf derKarte,die abgestimmt wurdeSpielleitung beendet es,oder tagelang RuheSpielleitung beendet es,oder tagelang Ruhe24 h später (Beat)LobbySpiel läuftSimulationzwischen den RundenbeendetanonymisiertDie Spielleitung kannjederzeit pausieren:paused_at hält Züge,Phasenwechsel und dasRundenende an, bis esweitergeht.
Schrittwo man liest
anlegenGameSessionListCreateView in game/views_rest.py
beitreten, Zuhause und ZieleJoinSessionAPIView in game/views_join.py, set_up_player und assign_agent_nodes in game/signals.py
startenGameSessionDetailView.update in game/views_rest.py
Runde komplett, simulieren, beendengame/rounds.py, dann handle_round_completed in game/signals.py
pausieren, Ende nach Ruhe, anonymisierengame/pause.py, game/idle.py, game/anon.py

Zugang für Mitspielende

Mitspielende haben kein Konto. Beim Beitreten bekommt der Browser zwei signierte Cookies, und jede Anfrage und jeder Socket wird gegen sie geprüft. Die Spielleitung ist ein normaler Django-User mit Sitzung und wird zuerst geprüft.

Einen Platz bekommen

die alte player_idnennt jetzt niemandenCode eingelöstneu oder erneuertbeitreten — POSTapi/game/join/ID/Name + Spielpasswortabgelehnt, wenn gestartet,beendet oder vollSpielleitung legtdas Spiel an(ihre eigene Zeile)whoami — GET api/whoami/erneuert beide, z. B. nacheiner Pauseein Platz zieht umPlatz-Code — POSTapi/game/seat/CODE/6 Zeichen, 5 Minuten,einmal gültigSpielleitung übernimmteinen Platz…/player/P/takeover/_rotate — game/seats.pydieselbe Zeile, dieselbenZüge und Stimmen,neue player_idplayer.revokedalte Sockets schließen mit4403set_game_access_cookie+ set_player_cookieco2mmute/utils.pyzwei Cookies,TimestampSigner, Salt ausSECRET_KEYgame_access_ID ='ID:Zufallstoken'player_ID = 'ID:player_id'

Ein Platz ist eine Player-Zeile und kann zwischen Geräten wandern: Die Spielleitung holt einen Platz an die Leitstelle („Übernehmen“) oder gibt ihn mit einem Code weiter. Keins von beidem kopiert die Zeile. _rotate gibt ihr eine neue player_id, und jedes Cookie, das die alte nennt, ist damit wertlos. Ein übernommener Platz braucht kein Cookie — die Spielleitung handelt mit ihrer Sitzung für ihn.

Jede Anfrage

janeinneinjaneinjaneinjaREST-AufrufHasGameAccess +IsPlayerInGamegame/permissions.pySocket-Verbindungresolve_playergame/ws_auth.pySpielleitungdieses Spiels?SpielleitungSocket: ein HostPlayerREST: darf für einen Platzan der Leitstelle handelnSpiel-Cookiegültig?abgelehntREST 403, Socket 4401Platz-Cookiegültig?Platz nochda?abgelehntREST 403, Socket 4403spielt diesen Platz
  • Spielleitung dieses Spiels? Ein angemeldeter Django-User, der game_host dieses Spiels ist.
  • Spiel-Cookie gültig? has_game_access: game_access_ID hat eine gültige Signatur, ist nicht abgelaufen, und die Spiel-ID darin ist die dieses Spiels.
  • Platz-Cookie gültig? resolve_player_id: dieselben Prüfungen für player_ID. Bei REST muss seine player_id außerdem die aus der URL sein — jede player_id steht im Roster der Lobby, ohne diese Prüfung könnte also jeder für jeden anderen ziehen.
  • Platz noch da? Eine Player-Zeile mit dieser player_id, die das Spiel nicht verlassen hat. Der Socket lehnt außerdem ein beendetes Spiel ab.

Beide Türen gehen durch game/auth.py, das nichts von der Spielleitung weiß. Ein abgelehnter Socket wird erst angenommen und dann mit seinem Code geschlossen (refuse() in game/consumers.py) — vor dem Annehmen geschlossen, sieht der Browser nur 1006 und verbindet sich immer wieder neu.

Zwei Folgen: Wer SECRET_KEY wechselt oder ändert, was in einem Cookie steht, wirft alle Mitspielenden aus allen laufenden Spielen. Und der Name ist das Einzige, was das Spiel über eine Person weiß; er landet nie in einem Log.

Eine Runde vom Zug bis zur Statistik

Vom ersten Tippen auf dem Handy bis zur Statistik auf jedem Bildschirm. Die Route wird im Browser gefunden, der Server prüft sie nur. Die Runde ist komplett, sobald der letzte Platz, der noch mitspielt, gezogen hat; dann läuft die Simulation auf Celery und meldet sich über den Websocket zurück.

POSTapi/game/ID/player/P/move/nach dem Commitrun_simulation_task.delaySimulationResult und seineZeilenneinRoster: dieser Platz wartetsimulation.progressround.completedja: game.endedHandy, RoundScreenVerkehrsmittel je Gruppewählen,der Router im Browserfindet Hin- und RückwegPlayerMoveView —game/views_rest.pybeide Wege prüfen,AgentRoutes speichern,200 ans Handycomplete_round_if_ready— game/rounds.pyhaben alle spielendenPlätze gezogen?dann die Rundebeanspruchen, ACTIVE zuCOMPLETEDCelery-Workerround_completed,handle_round_completedTrafficSimulator,run_simulationCelery-WorkerZahlen je PlatzCO2-Budget verbrauchtoder letzte Runde?Phase STATSGameConsumer, jederoffene SocketHandy: gameReducer,dann die Statistik

Zwei Regeln halten das zusammen. Es gibt einen Entscheidungspunkt, complete_round_if_ready, und alles, was eine Runde abschließen kann (ein Zug, jemand geht), kommt über schedule_round_completion_check dorthin. Und die Runde wird mit einem bedingten update() beansprucht, sodass zwei Züge, die im selben Augenblick ankommen, eine Simulation starten und nicht zwei.

Routensuche im Browser

Alles hier läuft auf dem Handy (hooks/use-round-draft.ts). Der Graph kommt einmal je Runde und Version vom Server, mit den gemessenen Geschwindigkeiten der letzten Runde dran; die Suche selbst ist Dijkstra.

janeinneinjaneinjakeinergefundenjader Platz: Zuhause + einZiel je GruppeuseSeatGameuseRoundDrafthooks/use-round-draft.tsGraph der aktivenKartenversion+ Geschwindigkeiten derletzten Runde,previous_round_trafficuseMapGraph —lib/queries/map-graph.tsLuftlinie über demLimit des Verkehrsmittels?das Verkehrsmittel istausgegrautVerkehrsmittel und OptionwählenÖPNV?findPath, dijkstrautils/pathfinding.tscalculateEdgeWeight jeKantecanUseEdge: darf dasVerkehrsmittel überhauptdrauf?zu Fuß, Rad: MinutenAuto schnellste: Minutenbei der Geschwindigkeitder letzten RundeAuto kürzeste: MeterAuto sparsamste: Meter ×CO2-Faktor bei dieserGeschwindigkeitRoute überdem Limit?too-farHinweg gefundenfindBestPTRoute,findPTRouteutils/ptRouting.tsDijkstra über Knoten +ZustandZustand: zu Fuß oder inLinie Xhöchstens 2 km zu Fuß zurHaltestelle und von ihr wegEinsteigen kostet denhalben Takt der Liniedieselbe Suche, vom Zielnach Hausenie der umgedrehteHinwegno-way-homeGruppe fertigalle Gruppen fertig?draftPayload, POST movelib/queries/move.ts
  • Die Limits sind 5 km zu Fuß und 15 km mit dem Rad (lib/map/trip-limits.ts). Die Luftlinie wird geprüft, bevor ein Verkehrsmittel gewählt ist — sie kann nur kürzer sein als die Route —, die gefundene Route danach.
  • Der Rückweg ist eine eigene Suche. Eine Einbahnstraße hat keine Gegenkante, auf so einer Karte ist der Rückweg also eine andere Route. Der Server lehnt einen Zug ohne Rückweg ab.
  • Der Server prüft, er sucht keine Route. _validate_routes in game/views_rest.py prüft, dass beide Wege Zuhause und Ziel verbinden und dass jede Kante das Verkehrsmittel erlaubt.
  • Der ÖPNV hat drei Optionen: „schnellste“, „wenig umsteigen“ (jeder Umstieg nach dem ersten Einsteigen kostet in der Suche 30 Minuten extra) und „ohne Bus“.
  • Eine langsame Suche kann keine neuere überschreiben. Jede Suche trägt ein Token je Gruppe; ein Ergebnis, dessen Token nicht mehr das aktuelle ist, wird verworfen.
  • Angefangene Eingaben überleben ein Neuladen (nur Verkehrsmittel und Optionen, nie eine Route), im localStorage, lib/game/draft-storage.ts.

Die Simulation

Ein Warteschlangenmodell je Kante (Link Queue), das mesoskopische Modell, das MATSim benutzt. Was es rechnet und warum, steht im Hintergrund; diese drei Diagramme zeigen, wie der Code aufgebaut ist.

Zeilen rein, Engine, Zeilen raus

game/simulation.py ist der Adapter: Er liest die Zeilen der Runde in ein Scenario, lässt die Engine laufen und schreibt die Ergebnisse. Alles unter sim/ ist reines Python ohne Django, ein Test oder ein Kalibrierskript kann also eine Runde ohne Datenbank rechnen.

Zeilen rausjaneinZeilen rein: _scenario_read_routesAgentRoutes einerRichtung_read_linesBus- und Bahnlinien deraktiven Version,jede auf ihrer eigenenStraßenseite_read_linksjede Kante, die eine Routeoder Linie befährthandle_round_completedgame/signals.pyTrafficSimulator(round)game/simulation.pyScenariosim/scenario.py, ab hierkein DjangoLinkQueueEnginesim/linkqueue.pyKanten mit je einergezogenen Kapazität,Fahrpläne der Linien, woMitfahrende einsteigenHinweg_run_pass,_compute_outcomesRückwege da?Rückwegein frisches Netz, derselbeZufallsstrom_save_resultsein AgentSimulationResultje Hin- und Rückweg,EdgeTrafficSnapshots,Summen_update_street_speedsStreetPerRound, daraufroutet die nächste Rundebuild_replaySimulationResult.replayzurück inhandle_round_completed:Zahlen je Platz,round.completed

Der Zufall einer Runde ist aus ihrem pk geseedet (TrafficSimulator(round, seed=…), um über mehrere Seeds zu messen), eine Runde läuft also bei jeder Wiederholung gleich.

Ein Durchlauf, Tick für Tick

Ein Durchlauf ist eine Richtung. Er läuft, bis alle angekommen sind; die Grenze von 1000 Ticks (PASS_TICK_GUARD) fängt nur einen Bug ab, und wird sie erreicht, steht ein Fehler im Log.

ja, vielleicht ist eine Tür freineinneinja_generate_departuresMenschen: normalverteiltum die StundeLinien: eine Fahrt je Takteine Warteliste fürs ganzeNetz,sortiert nach gewünschterAbfahrtWunschgeschwindigkeiteinmal je Person gezogenein Tick, standardmäßig 5Minuten_advance_trafficjede Kante bekommt dasAbflussbudget diesesTicks_spawn_vehicleslosfahren, wenn auf derersten Kante Platz ist,sonst an der Haustürwarten_advance_free_runningzu Fuß, Räder abseits derStraße, Züge,Busse auf der Busspur;Haltestellen werdenunterwegs bedient_discharge auf jeder Kante,jeden Tick in neuerzufälliger Reihenfolge(Reißverschluss)hat sich etwasbewegt?_spawn_vehicles noch mal_record_edge_trafficMesswerte der Straßen fürdie Wiedergabealle angekommen?_record_arrivals,_compute_outcomes: Zeit,Verspätung, CO2, Kosten,Fahrpreis

Der ÖPNV ist kein eigenes Modell. Eine Bus- oder Bahnfahrt ist ein ganz normales Fahrzeug auf denselben Kanten, unter einem negativen Routenschlüssel (route_pk < 0 heißt „eine Linie, kein Mensch“). Mitfahrende warten in stop_queues je Linie und Haltestelle, steigen ein, solange Plätze frei sind, und steigen aus, wo ihre Route die Linie verlässt (_serve_stop). Nach ihrem Fahrplan fährt eine Linie weiter, solange überhaupt noch jemand unterwegs ist.

Eine Kante

Was einem Auto auf einer Kante passiert. Eine Straße mit zwei Richtungen sind zwei Kanten, eine je Richtung.

neinjaneinjaAuto will auf die Kantenoch Platz?Speicher: 133 Autosje Spur und kmwartet, wo es ist,und blockiert damit dieKante dahinterfährt die FreiflusszeitLänge ÷ Tempolimit,mit seiner eigenenWunschgeschwindigkeitstellt sich in die Schlangedran, und noch Abfluss indiesem Tick?1800 Autos je Spur undStundeauf die nächste Kante,oder angekommen
  • Wer Schlange steht: Autos und Busse im Mischverkehr. Ein Rad auf einer Straße ohne Radspur hat auf der Kante eine eigene Schlange (bike_queue): Es nimmt Platz weg, wartet aber nie auf ein Auto. Wer zu Fuß geht, Züge, Wege ohne Straße und Busspuren laufen frei.
  • Zwei oder mehr Autospuren bekommen eine Schlange je nächster Straße (_pick_head), ein Auto, das links abbiegt, hält also die nicht auf, die geradeaus fahren.
  • Eine volle Kante, die nie leer wird, lässt nach DEADLOCK_TICKS = 4 trotzdem ein Auto weiter, über ihren Speicher hinaus. Das wird gezählt (forced_releases im Log).
  • Geschwindigkeit ist ein Ergebnis. Die Geschwindigkeit einer Kante ist ihre Länge durch die Zeit, die Autos wirklich gebraucht haben (EdgeState.mean_speed_kmh). Sie bestimmt CO2 und Kosten eines Autos auf dieser Kante, und die nächste Runde routet darauf.

Zwischen den Runden

Die Phasen stehen in game/phases.py. Die Handys und die Spielleitung schicken ihren Teil über den Spiel-Socket (player.stats_ack, vote.open, vote.submit, stalemate.vote, stalemate.force_leave); GameConsumer reicht sie nur weiter. Die Namen in Großbuchstaben sind die Phasen aus GameRound.BetweenRoundPhase.

round.completedalle haben die Statistikgelesen, und es gibt einenStimmzettelalle haben die Statistikgelesen, kein StimmzettelSpielleitung eröffnet dieAbstimmungein Gewinner, die neueactive_map_versionerster Gleichstandzweiter Gleichstand, dieKarte bleibtMehrheit für Neuwahlkeine Mehrheit, oder dieSpielleitung bricht abneue GameRound,round.startednächste RundeSTATSDISCUSSIONVOTINGSTALEMATE
  • Jeder Pfeil wird beansprucht. _claim setzt GameRound.between_round_phase mit einem bedingten update(), zwei Handys, die eine Phase im selben Augenblick abschließen, schalten sie also nur einmal weiter.
  • „Alle“ heißt die Plätze, die noch mitspielen — Player.objects.filter(game=…).playing(), dieselbe Regel wie in der Runde. Geht jemand, läuft phases.recheck, weil das eine Phase abschließen kann.
  • Der Stimmzettel wird einmal gezogen (vote_options, höchstens zwei Versionen) und an der Runde gespeichert, jedes Handy stimmt also über dieselben zwei ab.
  • Ein Spiel, das endet, hat keine Statistikphase. Seine letzten Zahlen kommen mit round.completed und in der Zusammenfassung bei den Mitspielenden an.

Vom Server auf den Bildschirm

Ein Socket je Spiel, ein Reducer. Der REST-Snapshot wird einmal gelesen, damit sofort etwas zu sehen ist; danach gehört jedes Update dem Socket.

game.state bei jederVerbindung,dann jedes EventStatistik gelesen, StimmencurrentScreendatabase_sync_to_async,in game/phases.pyein Bildschirm, je nach PhaseLobbySpiel läuftRoundScreen,HostDeskScreenzwischen den RundenBetweenScreen,HostBetweenScreenbeendetEndScreenBackend, synchroner Codegame/signals.pybeitreten, starten,beenden, round.completedgame/phases.pyStatistik, Abstimmung,Gleichstandgame/roster.pywer da ist, wer gezogenhatChannel Layergroup gamestate_IDGameConsumergame/consumers.pyGameSocket, einer je Spiellib/game/socket.tsREST-Snapshot, einmalgelesenuseLobbySnapshotgameReducerlib/game/game-state.ts
  • game.state kommt bei jeder Verbindung, ein Reconnect ist also schon der Abgleich. Nichts fragt regelmäßig nach, nichts lädt neu.
  • Der Socket schlägt den Snapshot. Der Roster kann vor dem Snapshot ankommen, und nur der Socket weiß, wer verbunden ist — sobald ein Roster da ist, rührt der Snapshot die Plätze also nicht mehr an.
  • lib/game/events.ts ist der ganze Socket-Vertrag als eine typisierte Union. Ein neues Event, das der Reducer nicht behandelt, bricht den Build.
  • Gesendet wird nur aus synchronem Code. send_game_state_message benutzt async_to_sync, und das verweigert auf der Event-Loop des Consumers den Dienst.

Kartenversionen und Abstimmung

Eine Karte ist ein Graph; eine Version ist ein Filter darüber. Bei jedem Knoten, jeder Kante, jeder Linie und jedem Linienabschnitt steht, in welchen Versionen sie vorkommen, und eine Zeile, die keine Version nennt, kommt in keiner vor.

compatible_versionsGameMapMapVersionsBasis, einzelneÄnderungen,Kombinationenjede Zeile nennt dieVersionen, in denen sie istvote_optionsgame/phases.py,höchstens zweidie Mitspielenden stimmenabGameSession.active_map_versionwas in dieser Version istKnoten und Kanten: ihremap_versionsLinienketten:maps/versions.pyMapVersionGraphViewapi/maps/ID/graph/version/V/?game=…TrafficSimulator_read_lines, _read_linksStreetPerRoundGeschwindigkeiten derletzten Rundeder Router im Browser

Die ganze Karte, mit jeder Version und dem Stimmzettel, geht als eine JSON-Datei rein und raus (api/maps/import/, api/maps/ID/export/). Ein Import legt immer eine neue Karte an.

Ziel: zurück ins Spiel

Und jetzt im Spiel sehen, wie es läuft

Jede Box hier passiert in einer Runde. Am schnellsten versteht man sie, wenn man eine spielt.