From 094a791da23d74f2da82c25199c8b7bda33b49dc Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Sun, 4 Oct 2026 00:46:32 +0200 Subject: [PATCH] Liegestuetze: pro Stream mitzaehlen, wie viele gemacht sind Herunterzaehlen mit -1/-5/-10 zaehlt als gemacht, Setzen bleibt eine Korrektur und zaehlt nicht. Der Stand "gemacht" beginnt mit jedem neuen Stream bei 0 (nicht beim Neustart des Hubs mitten im Stream), steht im Overlay unter der offenen Zahl und laesst sich auf der Uebersicht korrigieren. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/hub/src/index.ts | 37 +++++++----- apps/hub/src/liegestuetze.test.ts | 45 +++++++++++---- apps/hub/src/liegestuetze.ts | 59 ++++++++++++++------ apps/overlay-server/src/bus.test.ts | 8 +-- apps/overlay-server/src/bus.ts | 14 +++-- apps/overlay-server/src/server.ts | 68 ++++++++++++++++------- apps/overlay-web/public/liegestuetze.html | 61 +++++++++++++++++--- 7 files changed, 213 insertions(+), 79 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 0cd5d7d..9140f61 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -99,7 +99,7 @@ export async function runHub(): Promise { const liegestuetze = createLiegestuetze(db); // Gleich zu Beginn in den Bus, sonst zeigt eine frisch verbundene // Browser-Source 0, bis die naechste Einloesung kommt. - bus.publish({ kind: 'liegestuetze', offen: liegestuetze.stand() }); + bus.publish({ kind: 'liegestuetze', ...liegestuetze.stand() }); /** * Eine Einloesung abschliessen (FULFILLED) oder die Punkte zurueckgeben @@ -196,8 +196,7 @@ export async function runHub(): Promise { if (ent.aktion === 'emote-regen') bus.publish({ kind: 'hype', score: 5, rate: 40 }); if (ent.aktion === 'liegestuetze') { const dazu = ent.plan.anzahl ?? 5; - const offen = liegestuetze.aendern(dazu); - bus.publish({ kind: 'liegestuetze', offen, dazu, name: e.userName }); + bus.publish({ kind: 'liegestuetze', ...liegestuetze.aendern(dazu), dazu, name: e.userName }); } log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`); void einloesungSetzen(e.rewardId, e.id, 'FULFILLED'); @@ -331,7 +330,7 @@ export async function runHub(): Promise { goLive: announcer.lastResult() ?? 'noch nichts', overlayClients: bus.clients(), ttsWartend: warteschlange.liste().length, - liegestuetzeOffen: liegestuetze.stand(), + liegestuetze: liegestuetze.stand(), ttsStimme: stimmeFehlt.length ? `fehlt: ${stimmeFehlt.join(', ')}` : 'bereit', spikeArmed: spikes.isArmed(), note: state.chatAnonymous @@ -374,23 +373,35 @@ export async function runHub(): Promise { liegestuetze: { stand: () => liegestuetze.stand(), aendern: (n) => { - const offen = liegestuetze.aendern(n); - bus.publish({ kind: 'liegestuetze', offen }); - log.info(`Liegestuetze von Hand ${n > 0 ? '+' : ''}${n} -> ${offen} offen`); - return offen; + const neu = liegestuetze.aendern(n); + bus.publish({ kind: 'liegestuetze', ...neu }); + log.info(`Liegestuetze von Hand ${n > 0 ? '+' : ''}${n} -> ` + + `${neu.offen} offen, ${neu.gemacht} gemacht`); + return neu; }, setzen: (n) => { - const offen = liegestuetze.setzen(n); - bus.publish({ kind: 'liegestuetze', offen }); - log.info(`Liegestuetze von Hand gesetzt -> ${offen} offen`); - return offen; + const neu = liegestuetze.setzen(n); + bus.publish({ kind: 'liegestuetze', ...neu }); + log.info(`Liegestuetze von Hand gesetzt -> ${neu.offen} offen`); + return neu; + }, + gemachtSetzen: (n) => { + const neu = liegestuetze.gemachtSetzen(n); + bus.publish({ kind: 'liegestuetze', ...neu }); + log.info(`Liegestuetze gemacht von Hand gesetzt -> ${neu.gemacht}`); + return neu; }, }, }); void server; // ---- Zuschauerzahlen und Stream-Sitzungen - void pollStream(cfg, repo, state, bus, abort.signal, (stream) => announcer.announce(stream)); + void pollStream(cfg, repo, state, bus, abort.signal, (stream) => { + announcer.announce(stream); + // Nur bei einem wirklich neuen Stream, nicht beim Neustart des Hubs + // mitten im Stream: dann wird die Sitzung uebernommen, nicht gestartet. + bus.publish({ kind: 'liegestuetze', ...liegestuetze.neuerStream() }); + }); // ---- EventSub erst, wenn ein Konto verbunden ist. Ohne Nutzer-Token // wuerde der Verbindungsversuch im Minutentakt scheitern und nichts als diff --git a/apps/hub/src/liegestuetze.test.ts b/apps/hub/src/liegestuetze.test.ts index 85a6325..8c7d11b 100644 --- a/apps/hub/src/liegestuetze.test.ts +++ b/apps/hub/src/liegestuetze.test.ts @@ -1,5 +1,6 @@ -// Der Zaehler haelt fest, was Zuschauer mit Kanalpunkten bezahlt haben. Er -// darf weder beim Neustart vergessen noch ins Negative laufen. +// Der Zaehler haelt fest, was Zuschauer mit Kanalpunkten bezahlt haben und +// was Abdu davon geschafft hat. Er darf weder beim Neustart vergessen noch +// ins Negative laufen, und eine Korrektur ist keine Liegestuetze. import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -25,23 +26,43 @@ function oeffnen(file: string): DatabaseSync { test('Liegestuetze: zaehlt hoch und herunter, nie unter 0', () => { const lz = createLiegestuetze(oeffnen(datei())); - assert.equal(lz.stand(), 0); - assert.equal(lz.aendern(10), 10); - assert.equal(lz.aendern(10), 20); - assert.equal(lz.aendern(-5), 15); - assert.equal(lz.aendern(-100), 0); - assert.equal(lz.setzen(42), 42); - assert.equal(lz.setzen(-3), 0); - assert.equal(lz.setzen(10_000_000), HOECHSTENS); + assert.deepEqual(lz.stand(), { offen: 0, gemacht: 0 }); + assert.equal(lz.aendern(10).offen, 10); + assert.equal(lz.aendern(10).offen, 20); + assert.equal(lz.aendern(-5).offen, 15); + assert.equal(lz.aendern(-100).offen, 0); + assert.equal(lz.setzen(42).offen, 42); + assert.equal(lz.setzen(-3).offen, 0); + assert.equal(lz.setzen(10_000_000).offen, HOECHSTENS); +}); + +test('Liegestuetze: Herunterzaehlen ist gemacht, Setzen nicht', () => { + const lz = createLiegestuetze(oeffnen(datei())); + lz.aendern(20); + assert.deepEqual(lz.aendern(-5), { offen: 15, gemacht: 5 }); + // Nur was wirklich offen war, kann gemacht sein. + assert.deepEqual(lz.aendern(-100), { offen: 0, gemacht: 20 }); + lz.aendern(10); + assert.deepEqual(lz.setzen(3), { offen: 3, gemacht: 20 }, 'Korrektur zaehlt nicht'); + assert.deepEqual(lz.gemachtSetzen(7), { offen: 3, gemacht: 7 }); +}); + +test('Liegestuetze: neuer Stream setzt gemacht zurueck, offen bleibt', () => { + const lz = createLiegestuetze(oeffnen(datei())); + lz.aendern(30); + lz.aendern(-10); + assert.deepEqual(lz.neuerStream(), { offen: 20, gemacht: 0 }); }); test('Liegestuetze: der Stand ueberlebt einen Neustart', () => { const file = datei(); const erst = oeffnen(file); - createLiegestuetze(erst).aendern(30); + const lz = createLiegestuetze(erst); + lz.aendern(30); + lz.aendern(-5); erst.close(); - assert.equal(createLiegestuetze(oeffnen(file)).stand(), 30); + assert.deepEqual(createLiegestuetze(oeffnen(file)).stand(), { offen: 25, gemacht: 5 }); }); test('Belohnung mit Aktion liegestuetze wird ausgefuehrt und traegt die Anzahl', () => { diff --git a/apps/hub/src/liegestuetze.ts b/apps/hub/src/liegestuetze.ts index cf3c127..0174a07 100644 --- a/apps/hub/src/liegestuetze.ts +++ b/apps/hub/src/liegestuetze.ts @@ -1,21 +1,34 @@ -// Offene Liegestuetze aus Kanalpunkten. +// Liegestuetze aus Kanalpunkten: was offen ist und was in diesem Stream +// schon gemacht wurde. // -// Eine Einloesung zaehlt hoch, Abdu zaehlt auf der Uebersichtsseite wieder -// herunter, wenn er sie gemacht hat - oder korrigiert, wenn etwas nicht -// stimmt. Der Stand liegt in der Datenbank: was die Zuschauer bezahlt haben, -// darf ein Neustart nicht loeschen. +// Eine Einloesung zaehlt `offen` hoch. Abdu zaehlt auf der Uebersichtsseite +// herunter, wenn er sie gemacht hat - das wandert nach `gemacht`. Setzen ist +// dagegen eine Korrektur und zaehlt nicht als gemacht: wer einen Tippfehler +// behebt, hat dabei keine Liegestuetze gemacht. +// +// Beides liegt in der Datenbank: was die Zuschauer bezahlt haben, darf ein +// Neustart nicht loeschen. `gemacht` beginnt mit jedem neuen Stream bei 0. import type { Db } from '#db/index.ts'; /** Mehr glaubt niemand, und ein Tippfehler mit sechs Nullen soll nicht im Bild stehen. */ export const HOECHSTENS = 99_999; +export type LiegestuetzStand = { offen: number; gemacht: number }; + export type Liegestuetze = { - stand: () => number; - /** Zaehlt um `n` hoch oder (negativ) herunter. Nie unter 0. Gibt den neuen Stand zurueck. */ - aendern: (n: number) => number; - /** Setzt den Stand fest. Gibt den neuen Stand zurueck. */ - setzen: (n: number) => number; + stand: () => LiegestuetzStand; + /** + * Zaehlt `offen` um `n` hoch oder (negativ) herunter, nie unter 0. Was + * dabei wirklich abgezogen wurde, kommt zu `gemacht`. + */ + aendern: (n: number) => LiegestuetzStand; + /** Setzt `offen` fest. Korrektur, zaehlt nicht als gemacht. */ + setzen: (n: number) => LiegestuetzStand; + /** Setzt `gemacht` fest. Korrektur. */ + gemachtSetzen: (n: number) => LiegestuetzStand; + /** Neuer Stream: `gemacht` auf 0, `offen` bleibt. */ + neuerStream: () => LiegestuetzStand; }; export function createLiegestuetze(db: Db, name = 'liegestuetze'): Liegestuetze { @@ -24,13 +37,27 @@ export function createLiegestuetze(db: Db, name = 'liegestuetze'): Liegestuetze INSERT INTO zaehler (name, wert, geaendert) VALUES (?, ?, ?) ON CONFLICT(name) DO UPDATE SET wert = excluded.wert, geaendert = excluded.geaendert `); + const OFFEN = name; + const GEMACHT = `${name}-gemacht`; - const stand = (): number => (lesen.get(name) as { wert: number } | undefined)?.wert ?? 0; - const setzen = (n: number): number => { - const wert = Math.min(HOECHSTENS, Math.max(0, Math.trunc(n))); - schreiben.run(name, wert, new Date().toISOString()); - return wert; + const wert = (n: string): number => (lesen.get(n) as { wert: number } | undefined)?.wert ?? 0; + const schreibe = (n: string, w: number): number => { + const sauber = Math.min(HOECHSTENS, Math.max(0, Math.trunc(w))); + schreiben.run(n, sauber, new Date().toISOString()); + return sauber; }; + const stand = (): LiegestuetzStand => ({ offen: wert(OFFEN), gemacht: wert(GEMACHT) }); - return { stand, setzen, aendern: (n) => setzen(stand() + n) }; + return { + stand, + aendern(n) { + const vorher = wert(OFFEN); + const nachher = schreibe(OFFEN, vorher + n); + if (nachher < vorher) schreibe(GEMACHT, wert(GEMACHT) + (vorher - nachher)); + return stand(); + }, + setzen(n) { schreibe(OFFEN, n); return stand(); }, + gemachtSetzen(n) { schreibe(GEMACHT, n); return stand(); }, + neuerStream() { schreibe(GEMACHT, 0); return stand(); }, + }; } diff --git a/apps/overlay-server/src/bus.test.ts b/apps/overlay-server/src/bus.test.ts index c911f84..aafe3a1 100644 --- a/apps/overlay-server/src/bus.test.ts +++ b/apps/overlay-server/src/bus.test.ts @@ -111,8 +111,8 @@ test('abgemeldete Zuhoerer bekommen nichts mehr', () => { test('der Schnappschuss traegt den letzten Stand der Liegestuetze', () => { const bus = createBus(); - assert.equal(schnappschuss(bus).liegestuetze, 0); - bus.publish({ kind: 'liegestuetze', offen: 20, dazu: 10, name: 'Kevin' }); - bus.publish({ kind: 'liegestuetze', offen: 15 }); - assert.equal(schnappschuss(bus).liegestuetze, 15); + assert.deepEqual(schnappschuss(bus).liegestuetze, { offen: 0, gemacht: 0 }); + bus.publish({ kind: 'liegestuetze', offen: 20, gemacht: 0, dazu: 10, name: 'Kevin' }); + bus.publish({ kind: 'liegestuetze', offen: 15, gemacht: 5 }); + assert.deepEqual(schnappschuss(bus).liegestuetze, { offen: 15, gemacht: 5 }); }); diff --git a/apps/overlay-server/src/bus.ts b/apps/overlay-server/src/bus.ts index 7d9f605..8215b11 100644 --- a/apps/overlay-server/src/bus.ts +++ b/apps/overlay-server/src/bus.ts @@ -66,12 +66,14 @@ export type StreamEvent = { }; /** - * Neuer Stand der offenen Liegestuetze. `dazu` und `name` nur bei einer - * Einloesung - eine Korrektur von Hand soll nicht aussehen wie eine. + * Neuer Stand der Liegestuetze: offen und in diesem Stream gemacht. `dazu` + * und `name` nur bei einer Einloesung - eine Korrektur von Hand soll nicht + * aussehen wie eine. */ export type LiegestuetzeEvent = { kind: 'liegestuetze'; offen: number; + gemacht: number; dazu?: number; name?: string; }; @@ -105,8 +107,8 @@ export type OverlayEvent = | { kind: 'beat'; t: number } | { kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[]; - /** Offene Liegestuetze, damit der Zaehler nach dem Neuverbinden stimmt. */ - liegestuetze: number; + /** Liegestuetze, damit der Zaehler nach dem Neuverbinden stimmt. */ + liegestuetze: { offen: number; gemacht: number }; }; /** @@ -134,7 +136,7 @@ export function createBus(): Bus { let stream: StreamEvent = { kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null, }; - let liegestuetze = 0; + let liegestuetze = { offen: 0, gemacht: 0 }; return { publish(ev) { @@ -149,7 +151,7 @@ export function createBus(): Bus { } else if (ev.kind === 'stream') { stream = ev; } else if (ev.kind === 'liegestuetze') { - liegestuetze = ev.offen; + liegestuetze = { offen: ev.offen, gemacht: ev.gemacht }; } else if (ev.kind === 'clear') { // Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der // naechste Neustart der Browser-Source es zurueck ins Bild. diff --git a/apps/overlay-server/src/server.ts b/apps/overlay-server/src/server.ts index 8e110b9..5856bd2 100644 --- a/apps/overlay-server/src/server.ts +++ b/apps/overlay-server/src/server.ts @@ -65,9 +65,11 @@ export const PAGES = [ { path: '/tts', file: 'tts.html', w: 600, h: 200, title: 'Vorlesen', hint: 'Liest freigegebene Nachrichten vor. Braucht Ton in OBS.' }, { path: '/liegestuetze', file: 'liegestuetze.html', w: 420, h: 200, - title: 'Liegestütze', hint: 'Offene Liegestütze aus Kanalpunkten. Zählt bei Einlösung hoch, korrigieren unten auf dieser Seite.' }, + title: 'Liegestütze', hint: 'Offene und in diesem Stream gemachte Liegestütze. Zählt bei Einlösung hoch, bedienen unten auf dieser Seite.' }, ] as const; +type LiegestuetzStand = { offen: number; gemacht: number }; + export type OverlayServer = { bus: Bus; zugang: Zugang; @@ -100,9 +102,12 @@ export type OverlayOptions = { }; /** Der Liegestuetz-Zaehler. Ohne ihn fehlt der Abschnitt auf der Seite. */ liegestuetze?: { - stand: () => number; - aendern: (n: number) => number; - setzen: (n: number) => number; + stand: () => LiegestuetzStand; + /** Negativ heisst gemacht: das Abgezogene zaehlt fuer diesen Stream. */ + aendern: (n: number) => LiegestuetzStand; + /** Korrekturen. Zaehlen nicht als gemacht. */ + setzen: (n: number) => LiegestuetzStand; + gemachtSetzen: (n: number) => LiegestuetzStand; }; }; @@ -295,16 +300,21 @@ async function handle( const n = zahlAus(form.get('wert')); const antwort = (text: string, code = 200): void => send(res, code, 'application/json; charset=utf-8', - JSON.stringify({ text, offen: opts.liegestuetze!.stand() })); + JSON.stringify({ text, ...opts.liegestuetze!.stand() })); if (n === null) return antwort('Keine ganze Zahl.', 400); if (was === 'aendern') { - const offen = opts.liegestuetze.aendern(n); - return antwort(`${n > 0 ? '+' : ''}${n}, jetzt ${offen} offen.`); + const neu = opts.liegestuetze.aendern(n); + return antwort(n < 0 + ? `${-n} gemacht. Noch ${neu.offen} offen, ${neu.gemacht} in diesem Stream.` + : `+${n}, jetzt ${neu.offen} offen.`); } + if (n < 0) return antwort('Weniger als 0 geht nicht.', 400); if (was === 'setzen') { - if (n < 0) return antwort('Weniger als 0 geht nicht.', 400); - return antwort(`Auf ${opts.liegestuetze.setzen(n)} gesetzt.`); + return antwort(`Offen auf ${opts.liegestuetze.setzen(n).offen} gesetzt.`); + } + if (was === 'gemacht-setzen') { + return antwort(`Gemacht auf ${opts.liegestuetze.gemachtSetzen(n).gemacht} gesetzt.`); } return antwort('Unbekannt.', 400); } @@ -469,7 +479,8 @@ const SEITEN_CSS = ` .wartend .hint { max-width: 60ch; word-break: break-word; } .lz-stand { font: 700 3rem/1 "Segoe UI Variable Display", "Segoe UI", system-ui, sans-serif; font-variant-numeric: tabular-nums; margin: .4rem 0 1rem; } - .lz-stand small { font-size: 1rem; font-weight: 400; color: #8b84a3; margin-left: .5rem; } + .lz-stand small { font-size: 1rem; font-weight: 400; color: #8b84a3; margin: 0 2rem 0 .5rem; } + .lz-stand .gemacht { color: #4ade80; } button.lz { margin-left: 0; min-width: 3.2rem; } input.lz { width: 6.5rem; padding: .2rem .6rem; font-size: .85rem; } button.tts { margin-left: .4rem; } @@ -513,9 +524,10 @@ Kanalpunkte zurück, sofern das verbundene Konto das darf.

 

` : ''; const lzFeld = opts.liegestuetze ? `

Liegestütze

-

Jede Einlösung zählt hoch. Hier herunterzählen, wenn sie gemacht -sind, oder den Stand korrigieren. Die Zuschauer sehen jede Änderung.

-
${opts.liegestuetze.stand()}offen
+

Jede Einlösung zählt hoch. Mit −1, −5, −10 herunterzählen, wenn +sie gemacht sind: das zählt für diesen Stream als gemacht. „Setzen“ ist nur +für Korrekturen und zählt nicht. Die Zuschauer sehen jede Änderung.

+
${opts.liegestuetze.stand().offen}offen${opts.liegestuetze.stand().gemacht}gemacht in diesem Stream
@@ -526,8 +538,13 @@ sind, oder den Stand korrigieren. Die Zuschauer sehen jede Änderung.

- - + + +
+
+ + + Beginnt bei jedem neuen Stream bei 0.

 

` : ''; const base = opts.publicUrl || `http://127.0.0.1:${opts.port}`; @@ -631,19 +648,21 @@ ${lzFeld} ? 'Stream läuft: ' + (ev.stream.title || 'ohne Titel') : 'verbunden - kein Stream'; setzeLive(ev.stream.live); - zeigeLiegestuetze(ev.liegestuetze); + if (ev.liegestuetze) zeigeLiegestuetze(ev.liegestuetze); } else if (ev.kind === 'stream') { setzeLive(ev.live); } else if (ev.kind === 'liegestuetze') { - zeigeLiegestuetze(ev.offen); + zeigeLiegestuetze(ev); } }; // Der Stand kommt über den Ereignisstrom, damit eine Einlösung auch hier // sofort sichtbar ist und zwei offene Fenster nicht auseinanderlaufen. - function zeigeLiegestuetze(n) { - const el = document.getElementById('lz-offen'); - if (el && typeof n === 'number') el.textContent = n.toLocaleString('de-DE'); + function zeigeLiegestuetze(st) { + for (const [id, n] of [['lz-offen', st.offen], ['lz-gemacht', st.gemacht]]) { + const el = document.getElementById(id); + if (el && typeof n === 'number') el.textContent = n.toLocaleString('de-DE'); + } } const lzMeldung = document.getElementById('lz-meldung'); async function lzRuf(was, wert) { @@ -653,7 +672,7 @@ ${lzFeld} body: new URLSearchParams({ was, wert: String(wert) }), }); const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status })); - zeigeLiegestuetze(body.offen); + zeigeLiegestuetze(body); lzMeldung.textContent = body.text || ''; lzMeldung.className = 'meldung' + (res.ok ? ' ok' : ' fehler'); setTimeout(() => { lzMeldung.innerHTML = ' '; lzMeldung.className = 'meldung'; }, 4000); @@ -671,6 +690,13 @@ ${lzFeld} feld.value = ''; }); document.getElementById('lz-null').addEventListener('click', () => lzRuf('setzen', 0)); + document.getElementById('lz-gemacht-form').addEventListener('submit', (e) => { + e.preventDefault(); + const feld = document.getElementById('lz-gemacht-wert'); + if (feld.value === '') return; + lzRuf('gemacht-setzen', feld.value); + feld.value = ''; + }); } let sagAb; diff --git a/apps/overlay-web/public/liegestuetze.html b/apps/overlay-web/public/liegestuetze.html index ec1a91e..a4b3be3 100644 --- a/apps/overlay-web/public/liegestuetze.html +++ b/apps/overlay-web/public/liegestuetze.html @@ -5,14 +5,16 @@ abdulive - Liegestütze