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.
+
` : '';
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