diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts
index 4dd93cc..0cd5d7d 100644
--- a/apps/hub/src/index.ts
+++ b/apps/hub/src/index.ts
@@ -37,6 +37,7 @@ import {
ausEventSub, createDrossel, createWarteschlange, entscheiden,
} from './rewards.ts';
import { createAudioAblage } from './audio-ablage.ts';
+import { createLiegestuetze } from './liegestuetze.ts';
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
import { createSpikeDetector } from '../../ingest/src/spike.ts';
import { createBus, type AlertEvent, type Bus } from '../../overlay-server/src/bus.ts';
@@ -95,6 +96,10 @@ export async function runHub(): Promise {
// ---- Kanalpunkte
const warteschlange = createWarteschlange();
const drossel = createDrossel();
+ 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() });
/**
* Eine Einloesung abschliessen (FULFILLED) oder die Punkte zurueckgeben
@@ -189,6 +194,11 @@ export async function runHub(): Promise {
});
if (ent.aktion === 'konfetti') bus.publish({ kind: 'konfetti', staerke: 1 });
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 });
+ }
log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`);
void einloesungSetzen(e.rewardId, e.id, 'FULFILLED');
break;
@@ -321,6 +331,7 @@ export async function runHub(): Promise {
goLive: announcer.lastResult() ?? 'noch nichts',
overlayClients: bus.clients(),
ttsWartend: warteschlange.liste().length,
+ liegestuetzeOffen: liegestuetze.stand(),
ttsStimme: stimmeFehlt.length ? `fehlt: ${stimmeFehlt.join(', ')}` : 'bereit',
spikeArmed: spikes.isArmed(),
note: state.chatAnonymous
@@ -360,6 +371,21 @@ export async function runHub(): Promise {
log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert');
},
},
+ 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;
+ },
+ setzen: (n) => {
+ const offen = liegestuetze.setzen(n);
+ bus.publish({ kind: 'liegestuetze', offen });
+ log.info(`Liegestuetze von Hand gesetzt -> ${offen} offen`);
+ return offen;
+ },
+ },
});
void server;
diff --git a/apps/hub/src/liegestuetze.test.ts b/apps/hub/src/liegestuetze.test.ts
new file mode 100644
index 0000000..85a6325
--- /dev/null
+++ b/apps/hub/src/liegestuetze.test.ts
@@ -0,0 +1,68 @@
+// Der Zaehler haelt fest, was Zuschauer mit Kanalpunkten bezahlt haben. Er
+// darf weder beim Neustart vergessen noch ins Negative laufen.
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { DatabaseSync } from 'node:sqlite';
+import { migrate } from '#db/index.ts';
+import { createLiegestuetze, HOECHSTENS } from './liegestuetze.ts';
+import { entscheiden, type RewardsConfig } from './rewards.ts';
+import { zahlAus } from '../../overlay-server/src/server.ts';
+
+function datei(): string {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'abdulive-lz-'));
+ return path.join(dir, 't.db');
+}
+
+function oeffnen(file: string): DatabaseSync {
+ const db = new DatabaseSync(file);
+ migrate(db);
+ return db;
+}
+
+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);
+});
+
+test('Liegestuetze: der Stand ueberlebt einen Neustart', () => {
+ const file = datei();
+ const erst = oeffnen(file);
+ createLiegestuetze(erst).aendern(30);
+ erst.close();
+
+ assert.equal(createLiegestuetze(oeffnen(file)).stand(), 30);
+});
+
+test('Belohnung mit Aktion liegestuetze wird ausgefuehrt und traegt die Anzahl', () => {
+ const cfg: RewardsConfig = {
+ enabled: true,
+ belohnungen: [{ titel: '5 Liegestütze', aktion: 'liegestuetze', anzahl: 5 }],
+ tts: { freigabe: true, maxZeichen: 200, blockliste: [], proPersonProStunde: 3 },
+ };
+ const ent = entscheiden({
+ id: 'r1', rewardId: 'x', rewardTitle: '5 liegestütze', userId: 'u1', userName: 'Kevin',
+ text: '', kosten: 2000, ts: '2026-10-04T20:00:00Z',
+ }, cfg);
+ assert.equal(ent.art, 'ausfuehren');
+ assert.ok(ent.art === 'ausfuehren' && ent.aktion === 'liegestuetze' && ent.plan.anzahl === 5);
+});
+
+test('zahlAus: nur ganze Zahlen, alles andere ist null', () => {
+ assert.equal(zahlAus('10'), 10);
+ assert.equal(zahlAus(' -5 '), -5);
+ assert.equal(zahlAus('+3'), 3);
+ for (const x of ['', 'abc', '1.5', '1e3', '9999999', null, undefined]) {
+ assert.equal(zahlAus(x), null, String(x));
+ }
+});
diff --git a/apps/hub/src/liegestuetze.ts b/apps/hub/src/liegestuetze.ts
new file mode 100644
index 0000000..cf3c127
--- /dev/null
+++ b/apps/hub/src/liegestuetze.ts
@@ -0,0 +1,36 @@
+// Offene Liegestuetze aus Kanalpunkten.
+//
+// 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.
+
+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 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;
+};
+
+export function createLiegestuetze(db: Db, name = 'liegestuetze'): Liegestuetze {
+ const lesen = db.prepare('SELECT wert FROM zaehler WHERE name = ?');
+ const schreiben = db.prepare(`
+ INSERT INTO zaehler (name, wert, geaendert) VALUES (?, ?, ?)
+ ON CONFLICT(name) DO UPDATE SET wert = excluded.wert, geaendert = excluded.geaendert
+ `);
+
+ 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;
+ };
+
+ return { stand, setzen, aendern: (n) => setzen(stand() + n) };
+}
diff --git a/apps/hub/src/rewards.ts b/apps/hub/src/rewards.ts
index 6fc8cff..636d1f9 100644
--- a/apps/hub/src/rewards.ts
+++ b/apps/hub/src/rewards.ts
@@ -31,7 +31,8 @@ export type Einloesung = {
ts: string;
};
-export type AktionsArt = 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts';
+export type AktionsArt =
+ | 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'liegestuetze' | 'nichts';
export type BelohnungsPlan = {
/** Titel der Belohnung auf Twitch. Gross-/Kleinschreibung egal. */
@@ -43,6 +44,8 @@ export type BelohnungsPlan = {
freigabe?: boolean;
/** Nur bei `tts`: laengerer Text wird abgelehnt statt gekuerzt. */
maxZeichen?: number;
+ /** Nur bei `liegestuetze`: so viele kommen je Einloesung dazu. Vorgabe 5. */
+ anzahl?: number;
};
export type RewardsConfig = {
diff --git a/apps/overlay-server/src/bus.test.ts b/apps/overlay-server/src/bus.test.ts
index c1a9d82..c911f84 100644
--- a/apps/overlay-server/src/bus.test.ts
+++ b/apps/overlay-server/src/bus.test.ts
@@ -108,3 +108,11 @@ test('abgemeldete Zuhoerer bekommen nichts mehr', () => {
assert.equal(n, 1);
assert.equal(bus.clients(), 0);
});
+
+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);
+});
diff --git a/apps/overlay-server/src/bus.ts b/apps/overlay-server/src/bus.ts
index d389946..7d9f605 100644
--- a/apps/overlay-server/src/bus.ts
+++ b/apps/overlay-server/src/bus.ts
@@ -65,6 +65,17 @@ export type StreamEvent = {
startedAt: string | null;
};
+/**
+ * Neuer Stand der offenen Liegestuetze. `dazu` und `name` nur bei einer
+ * Einloesung - eine Korrektur von Hand soll nicht aussehen wie eine.
+ */
+export type LiegestuetzeEvent = {
+ kind: 'liegestuetze';
+ offen: number;
+ dazu?: number;
+ name?: string;
+};
+
export type OverlayEvent =
| ChatEvent
| AlertEvent
@@ -88,10 +99,15 @@ export type OverlayEvent =
}
/** Alles sofort still. Fuer den Panikschalter. */
| { kind: 'still' }
+ | LiegestuetzeEvent
| { kind: 'scene'; scene: string }
| { kind: 'clear'; userId?: string; messageId?: string }
| { kind: 'beat'; t: number }
- | { kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[] };
+ | {
+ kind: 'state'; stream: StreamEvent; chat: ChatEvent[]; alerts: AlertEvent[];
+ /** Offene Liegestuetze, damit der Zaehler nach dem Neuverbinden stimmt. */
+ liegestuetze: number;
+ };
/**
* So viele Nachrichten bekommt ein frisch verbundenes Overlay mit. Mehr
@@ -118,6 +134,7 @@ export function createBus(): Bus {
let stream: StreamEvent = {
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
};
+ let liegestuetze = 0;
return {
publish(ev) {
@@ -131,6 +148,8 @@ export function createBus(): Bus {
if (alerts.length > KEEP_ALERTS) alerts.shift();
} else if (ev.kind === 'stream') {
stream = ev;
+ } else if (ev.kind === 'liegestuetze') {
+ liegestuetze = ev.offen;
} else if (ev.kind === 'clear') {
// Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der
// naechste Neustart der Browser-Source es zurueck ins Bild.
@@ -153,7 +172,7 @@ export function createBus(): Bus {
return () => { listeners.delete(fn); };
},
- snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts] }),
+ snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts], liegestuetze }),
clients: () => listeners.size,
};
}
diff --git a/apps/overlay-server/src/server.ts b/apps/overlay-server/src/server.ts
index 4761665..8e110b9 100644
--- a/apps/overlay-server/src/server.ts
+++ b/apps/overlay-server/src/server.ts
@@ -64,6 +64,8 @@ export const PAGES = [
title: 'Pausenbild', hint: 'Eigene Szene: "Gleich geht es los", Pause, Ende.' },
{ 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.' },
] as const;
export type OverlayServer = {
@@ -96,6 +98,12 @@ export type OverlayOptions = {
/** Die fertige WAV zu einer Nachricht, solange sie abholbar ist. */
audio: (id: string) => Buffer | undefined;
};
+ /** Der Liegestuetz-Zaehler. Ohne ihn fehlt der Abschnitt auf der Seite. */
+ liegestuetze?: {
+ stand: () => number;
+ aendern: (n: number) => number;
+ setzen: (n: number) => number;
+ };
};
export function startOverlayServer(opts: OverlayOptions): OverlayServer {
@@ -167,7 +175,11 @@ async function handle(
if (/\.(css|js|svg|png|jpg|woff2)$/.test(p)) return sendFile(res, p.replace(/^\/+/, ''));
// ---- Die Browser-Sources und ihr Datenstrom.
- const page = PAGES.find((x) => x.path === p || `/${x.file}` === p);
+ // Nur GET: unter /tts und /liegestuetze liegen auch die Bedienknoepfe der
+ // Uebersicht (POST, mit Anmeldung). Ohne die Einschraenkung bekamen die
+ // statt einer Antwort die Overlay-Seite - oder ohne Schluessel ein 403.
+ const page = req.method === 'GET' || req.method === 'HEAD'
+ ? PAGES.find((x) => x.path === p || `/${x.file}` === p) : undefined;
const audioId = /^\/tts\/audio\/([A-Za-z0-9]+)$/.exec(p)?.[1];
if (page || p === '/events' || audioId) {
if (!zugang.darfOverlay(req, url)) {
@@ -276,6 +288,27 @@ async function handle(
JSON.stringify({ wartend: opts.tts.wartend() }));
}
+ // ---- Liegestuetze von Hand korrigieren
+ if (p === '/liegestuetze' && req.method === 'POST' && opts.liegestuetze) {
+ const form = await readForm(req);
+ const was = form.get('was') ?? '';
+ 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() }));
+
+ 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.`);
+ }
+ if (was === 'setzen') {
+ if (n < 0) return antwort('Weniger als 0 geht nicht.', 400);
+ return antwort(`Auf ${opts.liegestuetze.setzen(n)} gesetzt.`);
+ }
+ return antwort('Unbekannt.', 400);
+ }
+
if (p === '/schluessel/neu' && req.method === 'POST') {
zugang.neuerOverlayKey();
return redirect(res, '/');
@@ -351,6 +384,13 @@ function readForm(req: http.IncomingMessage): Promise {
});
}
+/** Eingabe von der Seite in eine ganze Zahl. Alles andere ist `null`. */
+export function zahlAus(roh: string | null | undefined): number | null {
+ if (roh === null || roh === undefined) return null;
+ const s = roh.trim();
+ return /^[+-]?\d{1,6}$/.test(s) ? Number(s) : null;
+}
+
function redirect(res: http.ServerResponse, to: string): void {
res.writeHead(303, { location: to });
res.end();
@@ -427,6 +467,11 @@ const SEITEN_CSS = `
gap: 1rem; padding: .7rem .9rem; border-top: 1px solid #241f35; }
.wartend:first-child { border-top: 0; }
.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; }
+ 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; }
`;
@@ -466,6 +511,25 @@ Kanalpunkte zurück, sofern das verbundene Konto das darf.
„Sofort still" bricht auch ab, was gerade gesprochen wird.
` : '';
+ 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
+
+
+
+
+
+
+
+
+
+
` : '';
const base = opts.publicUrl || `http://127.0.0.1:${opts.port}`;
const rows = PAGES.map((p) => {
const id = `u${p.path.slice(1)}`;
@@ -524,6 +588,8 @@ ${testfeld}
${ttsFeld}
+${lzFeld}
+
Schlüssel