chore(prototype): state-model prototype for the realtime frontend

Throwaway, kept as a primary source rather than on main. Single self-contained
HTML file: free-play buttons plus six guided walkthroughs over a pure reducer.

It settled two things the spec had wrong or missing:

- The client must supply the message id. With a server-generated id there is
  nothing to reconcile the optimistically rendered message against when it
  comes back over the realtime channel, and it renders twice. Both cases are
  reproducible via the toggle at the top.
- An unread *count* cannot be derived without fetching, because seq counts
  events and not messages. Hence: fetch for DMs, a plain dot for channels.
This commit is contained in:
2026-08-14 18:19:18 +02:00
parent b93ca7dcb6
commit 5c76e12142

View File

@@ -0,0 +1,493 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prototyp — Frontend-Zustandsmodell nach dem WebSocket-Umbau</title>
<style>
:root {
--akzent: #b4491f;
--grund: #fbfaf8;
--papier: #ffffff;
--linie: #e3ded6;
--text: #23201d;
--leise: #7d7469;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 2.5rem 1.5rem 6rem;
background: var(--grund); color: var(--text);
font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
.huelle { max-width: 66rem; margin: 0 auto; }
h1 { font-size: 1.55rem; margin: 0 0 .4rem; letter-spacing: -.01em; }
h2 { font-size: .82rem; text-transform: uppercase; letter-spacing: .09em;
color: var(--leise); margin: 2.4rem 0 .9rem; font-weight: 600; }
p { margin: 0 0 1rem; }
.frage { background: var(--papier); border: 1px solid var(--linie);
border-left: 3px solid var(--akzent); padding: 1.1rem 1.3rem;
border-radius: 3px; margin-bottom: .5rem; }
.frage strong { display: block; margin-bottom: .35rem; }
.frage p:last-child { margin-bottom: 0; }
.klein { font-size: .87rem; color: var(--leise); }
.schalter { display: flex; gap: 1.6rem; flex-wrap: wrap; margin: 1rem 0 0; }
.schalter label { display: flex; gap: .5rem; align-items: flex-start;
font-size: .88rem; cursor: pointer; max-width: 30rem; }
.schalter input { margin-top: .35rem; }
.raster { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; }
@media (max-width: 62rem) { .raster { grid-template-columns: 1fr; } }
.tafel { background: var(--papier); border: 1px solid var(--linie);
border-radius: 3px; padding: 1.1rem 1.3rem; }
.server { border-bottom: 1px solid var(--linie); padding: .7rem 0; }
.server:last-child { border-bottom: 0; padding-bottom: 0; }
.server:first-child { padding-top: 0; }
.serverkopf { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
.name { font-weight: 600; }
.zustand { font-size: .78rem; padding: .1rem .5rem; border-radius: 2rem;
border: 1px solid var(--linie); color: var(--leise); white-space: nowrap; }
.zustand.verbunden { color: #1f6b3a; border-color: #bfe0cb; background: #f2faf5; }
.zustand.neuverbinden { color: #96601a; border-color: #ecd8b4; background: #fdf7ec; }
.zustand.getrennt { color: #8f2f2f; border-color: #eec8c8; background: #fdf3f3; }
.zustand.abgemeldet { color: var(--leise); background: #f3f1ed; }
.conv { display: flex; justify-content: space-between; gap: .8rem;
padding: .28rem 0 .28rem .9rem; font-size: .9rem; }
.conv.offen { border-left: 2px solid var(--akzent); padding-left: .75rem; font-weight: 600; }
.marke { font-size: .78rem; color: var(--akzent); white-space: nowrap; }
.msgs { margin: .35rem 0 .2rem 1.6rem; padding: 0; list-style: none;
font-size: .84rem; color: var(--leise); }
.msgs li { padding: .1rem 0; }
.msgs .pending { opacity: .5; font-style: italic; }
.msgs .deleted { text-decoration: line-through; opacity: .45; }
.msgs .seq { display: inline-block; min-width: 2.4rem; color: #b3aa9d; }
button { font: inherit; font-size: .87rem; padding: .42rem .85rem;
border: 1px solid var(--linie); background: var(--papier);
border-radius: 3px; cursor: pointer; color: var(--text); }
button:hover:not(:disabled) { border-color: var(--akzent); color: var(--akzent); }
button:disabled { opacity: .35; cursor: default; }
button.jetzt { border-color: var(--akzent); background: var(--akzent); color: #fff; }
.knopfreihe { display: flex; flex-wrap: wrap; gap: .5rem; }
.reiter { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1rem; }
.reiter button.aktiv { border-color: var(--akzent); color: var(--akzent); font-weight: 600; }
ol.schritte { margin: 1rem 0 0; padding: 0; list-style: none; }
ol.schritte li { display: flex; gap: .7rem; align-items: baseline;
padding: .3rem 0; border-top: 1px dotted var(--linie); }
ol.schritte li:first-child { border-top: 0; }
ol.schritte .nr { color: #b3aa9d; font-size: .8rem; min-width: 1.2rem; }
ol.schritte .erledigt { color: var(--leise); text-decoration: line-through; }
.protokoll { margin-top: 1.4rem; font-size: .84rem; color: var(--leise);
max-height: 13rem; overflow-y: auto; }
.protokoll div { padding: .16rem 0; border-top: 1px dotted var(--linie); }
.protokoll div:first-child { border-top: 0; }
.protokoll .neu { color: var(--akzent); }
footer { margin-top: 3rem; font-size: .82rem; color: var(--leise);
border-top: 1px solid var(--linie); padding-top: 1rem; }
</style>
</head>
<body>
<div class="huelle">
<h1>Frontend-Zustandsmodell nach dem WebSocket-Umbau</h1>
<p class="klein">Wegwerf-Prototyp zu Ticket 10 der Map <em>websocket-realtime</em>. Nur zum Durchklicken — kein Produktionscode.</p>
<div class="frage">
<strong>Die Frage, die dieser Prototyp beantwortet</strong>
<p>Der Client hält Verbindungen zu <strong>mehreren unabhängigen Servern</strong>. Der Rust-Layer besitzt die Verbindungen, führt die Cursor und lädt selbst nach; das Frontend bekommt <strong>fertige Entitäten</strong> geliefert und hat keine Cursor-Logik. Trägt dieses Zustandsmodell die unangenehmen Fälle — Nachricht in einer nicht geöffneten Conversation, optimistisch gesendete Nachricht, die gleich nochmal vom Server kommt, Bearbeitung, Löschung, erzwungener Neuabgleich, und ein einzelner ausgefallener Server neben funktionierenden?</p>
<p class="klein">Die beiden Schalter unten sind die eigentlich offenen Entscheidungen. Stell sie um und fahr dieselben Durchläufe nochmal — der Unterschied ist der Punkt.</p>
<div class="schalter">
<label><input type="checkbox" id="s-immer">
<span><strong>Rust lädt auch für nicht geöffnete Conversations nach.</strong> Aus: es kommt nur ein Signal, der Inhalt wird erst beim Öffnen geholt.</span></label>
<label><input type="checkbox" id="s-clientid" checked>
<span><strong>Der Client vergibt die Nachrichten-ID.</strong> Aus: der Server vergibt sie, der Client muss die eigene Nachricht am Echo wiedererkennen.</span></label>
</div>
</div>
<div class="raster">
<div>
<h2>Aktueller Zustand</h2>
<div class="tafel" id="zustand"></div>
<h2>Was gerade passiert ist</h2>
<div class="tafel protokoll" id="protokoll"></div>
</div>
<div>
<h2>Freies Ausprobieren</h2>
<div class="tafel">
<div class="knopfreihe" id="freispiel"></div>
</div>
<h2>Geführte Durchläufe</h2>
<div class="tafel">
<div class="reiter" id="reiter"></div>
<p id="szenario-text" class="klein"></p>
<ol class="schritte" id="schritte"></ol>
</div>
</div>
</div>
<footer>
Prototyp — wird nicht ausgeliefert. Der Reducer im ersten <code>&lt;script&gt;</code>-Block ist der Teil, der in echten Code übernommen werden kann; die Seite drumherum nicht.
</footer>
</div>
<script>
/* ------------------------------------------------------------------ *
* REINER REDUCER — dieser Block ist der übernehmbare Teil.
* Kein DOM, keine Knöpfe, keine Seite. (Zustand, Aktion) => Zustand.
* ------------------------------------------------------------------ */
const A = "server-a", B = "server-b";
function anfang() {
return {
optionen: { rustLaedtImmer: false, clientVergibtId: true },
offen: { serverId: A, conversationId: "allgemein" },
naechsteSeq: { "server-a/allgemein": 3, "server-a/design": 8, "server-b/team": 5 },
server: {
[A]: {
name: "chat.example.org", verbindung: "verbunden",
conversations: {
allgemein: { name: "#allgemein", geladen: true, ungelesen: 0, nachrichten: [
{ id: "m1", seq: 1, autor: "Ida", text: "Moin.", zustand: "ok" },
{ id: "m2", seq: 2, autor: "Ruben", text: "Läuft der Server?", zustand: "ok" },
]},
design: { name: "#design", geladen: false, ungelesen: 0, nachrichten: [] },
},
},
[B]: {
name: "intern.firma.de", verbindung: "verbunden",
conversations: {
team: { name: "#team", geladen: false, ungelesen: 0, nachrichten: [] },
},
},
},
};
}
const istOffen = (s, srv, conv) =>
s.offen.serverId === srv && s.offen.conversationId === conv;
function sortiert(liste) {
// Anzeigereihenfolge ist ausschliesslich seq. Optimistische Nachrichten
// haben noch keine und hängen hinten an.
return [...liste].sort((x, y) => (x.seq ?? Infinity) - (y.seq ?? Infinity));
}
function reduce(zustand, aktion) {
const s = structuredClone(zustand);
const notiz = (t) => { s.letzteNotiz = t; };
switch (aktion.art) {
case "option": {
s.optionen[aktion.name] = aktion.wert;
notiz(`Option „${aktion.name}“ ist jetzt ${aktion.wert ? "an" : "aus"}.`);
return s;
}
case "conversationGeoeffnet": {
const { serverId, conversationId } = aktion;
s.offen = { serverId, conversationId };
const c = s.server[serverId].conversations[conversationId];
if (!c.geladen) {
// Beim Öffnen wird geholt, was noch fehlt.
c.geladen = true;
c.nachrichten = aktion.inhalt ?? c.nachrichten;
notiz(`${c.name} geöffnet — Inhalt wurde beim Öffnen nachgeladen.`);
} else {
notiz(`${c.name} geöffnet.`);
}
c.ungelesen = 0;
return s;
}
/* Rust hat ein Signal bekommen, nachgeladen und liefert Entitäten hoch.
Ob überhaupt nachgeladen wurde, entscheidet die Option. */
case "entitaetenEingetroffen": {
const { serverId, conversationId, entitaeten } = aktion;
const srv = s.server[serverId];
const c = srv.conversations[conversationId];
const sichtbar = istOffen(s, serverId, conversationId);
if (!sichtbar && !s.optionen.rustLaedtImmer) {
// Nur ein Signal: wir wissen DASS sich etwas geändert hat, nicht WAS.
c.ungelesen = "•";
notiz(`${srv.name} · ${c.name}: Signal ohne Inhalt — Markierung gesetzt, Anzahl unbekannt.`);
return s;
}
for (const e of entitaeten) {
const i = c.nachrichten.findIndex((m) => m.id === e.id);
if (i === -1) {
c.nachrichten.push({ ...e, zustand: e.zustand ?? "ok" });
} else {
// Ersetzen, nicht anhängen. seq der Nachricht bleibt, also
// bewegt sich eine bearbeitete Nachricht NICHT im Verlauf.
c.nachrichten[i] = { ...c.nachrichten[i], ...e };
}
}
c.geladen = true;
c.nachrichten = sortiert(c.nachrichten);
if (sichtbar) {
c.ungelesen = 0;
notiz(`${c.name}: ${entitaeten.length} Entität(en) übernommen — offen, also direkt sichtbar.`);
} else {
c.ungelesen = (typeof c.ungelesen === "number" ? c.ungelesen : 0) + entitaeten.length;
notiz(`${srv.name} · ${c.name}: nachgeladen im Hintergrund, Anzahl bekannt (${c.ungelesen}).`);
}
return s;
}
/* Optimistisches Senden. */
case "gesendet": {
const { serverId, conversationId, text } = aktion;
const c = s.server[serverId].conversations[conversationId];
const id = s.optionen.clientVergibtId ? aktion.clientId : null;
c.nachrichten.push({
id, clientId: aktion.clientId, seq: null,
autor: "ich", text, zustand: "pending",
});
notiz(`Gesendet: „${text}“ — erscheint sofort, noch ohne seq.`);
return s;
}
/* Die eigene Nachricht kommt über den Realtime-Weg zurück. */
case "eigeneNachrichtZurueck": {
const { serverId, conversationId, clientId, text } = aktion;
const c = s.server[serverId].conversations[conversationId];
const schluessel = `${serverId}/${conversationId}`;
const seq = s.naechsteSeq[schluessel]++;
// Zusammenführen: nur möglich, wenn es etwas gibt, woran man
// die eigene Nachricht wiedererkennt.
const i = s.optionen.clientVergibtId
? c.nachrichten.findIndex((m) => m.clientId === clientId)
: -1;
if (i === -1) {
c.nachrichten.push({
id: `srv-${seq}`, seq, autor: "ich", text, zustand: "ok",
});
notiz(s.optionen.clientVergibtId
? `Zurück vom Server — nichts zum Zusammenführen gefunden, angehängt.`
: `DOPPELT: Server vergab eine eigene ID, die optimistische Nachricht bleibt daneben stehen.`);
} else {
c.nachrichten[i] = { ...c.nachrichten[i], id: clientId, seq, zustand: "ok" };
notiz(`Zurück vom Server — über die Client-ID zusammengeführt, kein Duplikat.`);
}
c.nachrichten = sortiert(c.nachrichten);
return s;
}
case "bearbeitet": {
const { serverId, conversationId, id, text } = aktion;
const c = s.server[serverId].conversations[conversationId];
const m = c.nachrichten.find((x) => x.id === id);
if (m) { m.text = text; m.bearbeitet = true; }
notiz(`Bearbeitet — seq bleibt ${m ? m.seq : "?"}, die Nachricht springt also nicht.`);
return s;
}
case "geloescht": {
const { serverId, conversationId, id } = aktion;
const c = s.server[serverId].conversations[conversationId];
const m = c.nachrichten.find((x) => x.id === id);
if (m) { m.zustand = "deleted"; m.text = "(gelöscht)"; }
notiz(`Gelöscht — kommt als Grabstein mit, nicht als Auslassung.`);
return s;
}
/* Server kann kein Delta bilden: Zwischenspeicher wegwerfen. */
case "resync": {
const { serverId, conversationId, inhalt } = aktion;
const srv = s.server[serverId];
const c = srv.conversations[conversationId];
const sichtbar = istOffen(s, serverId, conversationId);
c.nachrichten = sichtbar ? inhalt : [];
c.geladen = sichtbar;
c.ungelesen = sichtbar ? 0 : "•";
notiz(sichtbar
? `${c.name}: Neuabgleich — Zwischenspeicher verworfen und frisch geladen.`
: `${srv.name} · ${c.name}: Neuabgleich — Zwischenspeicher verworfen, Laden beim Öffnen.`);
return s;
}
case "verbindungVerloren": {
s.server[aktion.serverId].verbindung = "neuverbinden";
notiz(`${s.server[aktion.serverId].name} getrennt — versucht neu zu verbinden. Andere Server unberührt.`);
return s;
}
case "verbindungZurueck": {
const srv = s.server[aktion.serverId];
srv.verbindung = "verbunden";
notiz(`${srv.name} wieder verbunden — Rust zieht das Manifest und gleicht ab.`);
return s;
}
case "verdraengt": {
s.server[aktion.serverId].verbindung = "abgemeldet";
notiz(`${s.server[aktion.serverId].name}: von neuerer Verbindung verdrängt (4002) — es wird NICHT neu verbunden.`);
return s;
}
case "zuruecksetzen":
return { ...anfang(), optionen: zustand.optionen, letzteNotiz: "Zurückgesetzt." };
default:
return s;
}
}
/* ------------------------------------------------------------------ *
* Ende des übernehmbaren Teils. Alles darunter ist Wegwerf-Seite.
* ------------------------------------------------------------------ */
</script>
<script>
let zustand = anfang();
let protokoll = [];
let aktivesSzenario = 0;
let schrittIndex = 0;
const el = (id) => document.getElementById(id);
function schicke(aktion) {
zustand = reduce(zustand, aktion);
if (zustand.letzteNotiz) protokoll.unshift(zustand.letzteNotiz);
protokoll = protokoll.slice(0, 40);
zeichne();
}
/* ---- Aktionen für das freie Ausprobieren ---- */
let zaehler = 0;
const neuerText = () => ["Kurze Frage.", "Bin gleich da.", "Sieht gut aus.", "Passt so.", "Melde mich später."][zaehler++ % 5];
const freispiel = [
{ titel: "#allgemein öffnen", tu: () => schicke({ art: "conversationGeoeffnet", serverId: A, conversationId: "allgemein" }) },
{ titel: "#design öffnen", tu: () => schicke({ art: "conversationGeoeffnet", serverId: A, conversationId: "design",
inhalt: [{ id: "d1", seq: 6, autor: "Ida", text: "Entwurf liegt bereit.", zustand: "ok" },
{ id: "d2", seq: 7, autor: "Nour", text: "Schaue ich mir an.", zustand: "ok" }] }) },
{ titel: "#team öffnen (Server B)", tu: () => schicke({ art: "conversationGeoeffnet", serverId: B, conversationId: "team",
inhalt: [{ id: "t1", seq: 4, autor: "Milan", text: "Standup um zehn.", zustand: "ok" }] }) },
{ titel: "Neue Nachricht in #allgemein", tu: () => schicke({ art: "entitaetenEingetroffen", serverId: A, conversationId: "allgemein",
entitaeten: [{ id: `x${++zaehler}`, seq: zustand.naechsteSeq["server-a/allgemein"]++, autor: "Ruben", text: neuerText() }] }) },
{ titel: "Neue Nachricht in #design", tu: () => schicke({ art: "entitaetenEingetroffen", serverId: A, conversationId: "design",
entitaeten: [{ id: `y${++zaehler}`, seq: zustand.naechsteSeq["server-a/design"]++, autor: "Nour", text: neuerText() }] }) },
{ titel: "Neue Nachricht in #team (Server B)", tu: () => schicke({ art: "entitaetenEingetroffen", serverId: B, conversationId: "team",
entitaeten: [{ id: `z${++zaehler}`, seq: zustand.naechsteSeq["server-b/team"]++, autor: "Milan", text: neuerText() }] }) },
{ titel: "Ich sende etwas", tu: () => { const cid = `c${++zaehler}`; letzteEigene = { cid, text: neuerText() };
schicke({ art: "gesendet", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, clientId: cid, text: letzteEigene.text }); } },
{ titel: "…und sie kommt zurück", tu: () => { if (!letzteEigene) return;
schicke({ art: "eigeneNachrichtZurueck", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, clientId: letzteEigene.cid, text: letzteEigene.text }); } },
{ titel: "Älteste Nachricht bearbeiten", tu: () => { const c = aktuelleConv(); const m = c.nachrichten.find(x => x.zustand === "ok");
if (m) schicke({ art: "bearbeitet", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, id: m.id, text: m.text + " (korrigiert)" }); } },
{ titel: "Älteste Nachricht löschen", tu: () => { const c = aktuelleConv(); const m = c.nachrichten.find(x => x.zustand === "ok");
if (m) schicke({ art: "geloescht", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, id: m.id }); } },
{ titel: "Server verlangt Neuabgleich", tu: () => schicke({ art: "resync", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId,
inhalt: [{ id: "r1", seq: 20, autor: "Ida", text: "Frisch geladen nach Neuabgleich.", zustand: "ok" }] }) },
{ titel: "Server A verliert Verbindung", tu: () => schicke({ art: "verbindungVerloren", serverId: A }) },
{ titel: "Server A ist zurück", tu: () => schicke({ art: "verbindungZurueck", serverId: A }) },
{ titel: "Server A: verdrängt (4002)", tu: () => schicke({ art: "verdraengt", serverId: A }) },
{ titel: "Alles zurücksetzen", tu: () => { protokoll = []; letzteEigene = null; schicke({ art: "zuruecksetzen" }); } },
];
let letzteEigene = null;
const aktuelleConv = () => zustand.server[zustand.offen.serverId].conversations[zustand.offen.conversationId];
/* ---- Geführte Durchläufe ---- */
const szenarien = [
{ name: "Nachricht kommt an",
text: "Der einfache Fall. Eine Nachricht trifft in der geöffneten Conversation ein. Achte darauf, dass das Frontend nur fertige Entitäten einsetzt — es rechnet nichts aus und fragt nichts nach.",
schritte: [freispiel[0], freispiel[3], freispiel[3]] },
{ name: "Nachricht anderswo",
text: "Die Nachricht kommt in einer Conversation an, die NICHT geöffnet ist. Fahr diesen Durchlauf zweimal — einmal mit dem Schalter „Rust lädt auch für nicht geöffnete Conversations nach“ aus, einmal an. Aus bekommst du nur einen Punkt, weil niemand die Anzahl kennt; an bekommst du eine Zahl, aber Requests für Inhalt, den vielleicht niemand ansieht.",
schritte: [freispiel[0], freispiel[4], freispiel[4], freispiel[1]] },
{ name: "Optimistisch senden",
text: "Die eigene Nachricht erscheint sofort und kommt gleich darauf über den Realtime-Weg zurück. Mit Client-ID wird zusammengeführt. Schalte „Der Client vergibt die Nachrichten-ID“ AUS und fahr den Durchlauf erneut — dann steht dieselbe Nachricht zweimal da.",
schritte: [freispiel[0], freispiel[6], freispiel[7]] },
{ name: "Bearbeiten und Löschen",
text: "Eine bereits sichtbare Nachricht wird geändert und dann gelöscht. Achte auf die Position: die Sortierung folgt der seq der Nachricht, nicht dem Änderungszeitpunkt — sie springt also nicht ans Ende. Und Löschen erzeugt einen Grabstein, keine Lücke.",
schritte: [freispiel[0], freispiel[8], freispiel[9]] },
{ name: "Neuabgleich",
text: "Der Server kann kein Delta bilden — Cursor zu alt, Limit überschritten, oder der Listener hatte einen Aussetzer. Der Zwischenspeicher dieser Conversation wird verworfen und frisch geladen. Das ist derselbe Pfad, den auch die Erstverbindung nimmt.",
schritte: [freispiel[0], freispiel[3], freispiel[10]] },
{ name: "Ein Server fällt aus",
text: "Server A verliert die Verbindung, während Server B weiterläuft — der Zustand ist pro Server, nicht global. Danach wird Server A verdrängt (Close-Code 4002): dieser Zustand ist endgültig, es wird bewusst NICHT neu verbunden, sonst verdrängen sich zwei Geräte im Kreis.",
schritte: [freispiel[0], freispiel[11], freispiel[5], freispiel[12], freispiel[13]] },
];
/* ---- Zeichnen ---- */
function zeichne() {
el("s-immer").checked = zustand.optionen.rustLaedtImmer;
el("s-clientid").checked = zustand.optionen.clientVergibtId;
el("zustand").innerHTML = Object.entries(zustand.server).map(([sid, srv]) => {
const zk = { verbunden: "verbunden", neuverbinden: "verbindet neu", getrennt: "getrennt", abgemeldet: "abgemeldet (4002)" }[srv.verbindung];
const convs = Object.entries(srv.conversations).map(([cid, c]) => {
const offen = istOffen(zustand, sid, cid);
const marke = c.ungelesen === "•" ? "• ungelesen"
: (typeof c.ungelesen === "number" && c.ungelesen > 0) ? `${c.ungelesen} neu` : "";
const liste = offen && c.nachrichten.length
? `<ul class="msgs">${sortiert(c.nachrichten).map(m =>
`<li class="${m.zustand === "pending" ? "pending" : m.zustand === "deleted" ? "deleted" : ""}">` +
`<span class="seq">${m.seq ?? "—"}</span>${m.autor}: ${m.text}${m.bearbeitet ? " ✎" : ""}</li>`).join("")}</ul>`
: (offen ? `<ul class="msgs"><li>— leer —</li></ul>` : "");
return `<div class="conv ${offen ? "offen" : ""}"><span>${c.name}${c.geladen ? "" : ' <span class="klein">(nicht geladen)</span>'}</span><span class="marke">${marke}</span></div>${liste}`;
}).join("");
return `<div class="server"><div class="serverkopf"><span class="name">${srv.name}</span>` +
`<span class="zustand ${srv.verbindung}">${zk}</span></div>${convs}</div>`;
}).join("");
el("protokoll").innerHTML = protokoll.length
? protokoll.map((t, i) => `<div class="${i === 0 ? "neu" : ""}">${t}</div>`).join("")
: `<div class="klein">Noch nichts passiert. Drück einen Knopf.</div>`;
el("freispiel").innerHTML = "";
freispiel.forEach((a, i) => {
const b = document.createElement("button");
b.textContent = a.titel;
b.onclick = a.tu;
el("freispiel").appendChild(b);
});
el("reiter").innerHTML = "";
szenarien.forEach((sz, i) => {
const b = document.createElement("button");
b.textContent = sz.name;
if (i === aktivesSzenario) b.className = "aktiv";
b.onclick = () => { aktivesSzenario = i; schrittIndex = 0; protokoll = [];
letzteEigene = null; schicke({ art: "zuruecksetzen" }); };
el("reiter").appendChild(b);
});
const sz = szenarien[aktivesSzenario];
el("szenario-text").textContent = sz.text;
el("schritte").innerHTML = "";
sz.schritte.forEach((schritt, i) => {
const li = document.createElement("li");
const nr = document.createElement("span");
nr.className = "nr"; nr.textContent = `${i + 1}.`;
const b = document.createElement("button");
b.textContent = schritt.titel;
b.disabled = i !== schrittIndex;
if (i === schrittIndex) b.className = "jetzt";
if (i < schrittIndex) b.classList.add("erledigt");
b.onclick = () => { schritt.tu(); schrittIndex = i + 1; zeichne(); };
li.append(nr, b);
el("schritte").appendChild(li);
});
}
el("s-immer").onchange = (e) => schicke({ art: "option", name: "rustLaedtImmer", wert: e.target.checked });
el("s-clientid").onchange = (e) => schicke({ art: "option", name: "clientVergibtId", wert: e.target.checked });
zeichne();
</script>
</body>
</html>