Liegestuetze: Zaehler aus Kanalpunkten, Overlay und Korrektur von Hand

Neue Aktion `liegestuetze` fuer Belohnungen: jede Einloesung zaehlt um
`anzahl` hoch (Vorgabe 5). Der Stand liegt in der Datenbank und
uebersteht einen Neustart. Browser-Source /liegestuetze zeigt ihn, die
Uebersichtsseite hat Knoepfe zum Herunterzaehlen und Setzen.

Nebenbei: POST /tts landete bei der Overlay-Seite statt bei der
Freigabe. Seiten werden jetzt nur noch bei GET ausgeliefert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZeasyAlexandClaude Opus 5.5 committed 2026-10-04 00:15:50 +02:00
1 parent 5622499877
commit eb9eab949e
11 files changed
+469 -9

No files matched your search

+26
View File
@@ -37,6 +37,7 @@ import {
ausEventSub, createDrossel, createWarteschlange, entscheiden, ausEventSub, createDrossel, createWarteschlange, entscheiden,
} from './rewards.ts'; } from './rewards.ts';
import { createAudioAblage } from './audio-ablage.ts'; import { createAudioAblage } from './audio-ablage.ts';
import { createLiegestuetze } from './liegestuetze.ts';
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts'; import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
import { createSpikeDetector } from '../../ingest/src/spike.ts'; import { createSpikeDetector } from '../../ingest/src/spike.ts';
import { createBus, type AlertEvent, type Bus } from '../../overlay-server/src/bus.ts'; import { createBus, type AlertEvent, type Bus } from '../../overlay-server/src/bus.ts';
@@ -95,6 +96,10 @@ export async function runHub(): Promise<void> {
// ---- Kanalpunkte // ---- Kanalpunkte
const warteschlange = createWarteschlange(); const warteschlange = createWarteschlange();
const drossel = createDrossel(); 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 * Eine Einloesung abschliessen (FULFILLED) oder die Punkte zurueckgeben
@@ -189,6 +194,11 @@ export async function runHub(): Promise<void> {
}); });
if (ent.aktion === 'konfetti') bus.publish({ kind: 'konfetti', staerke: 1 }); 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 === '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}`); log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`);
void einloesungSetzen(e.rewardId, e.id, 'FULFILLED'); void einloesungSetzen(e.rewardId, e.id, 'FULFILLED');
break; break;
@@ -321,6 +331,7 @@ export async function runHub(): Promise<void> {
goLive: announcer.lastResult() ?? 'noch nichts', goLive: announcer.lastResult() ?? 'noch nichts',
overlayClients: bus.clients(), overlayClients: bus.clients(),
ttsWartend: warteschlange.liste().length, ttsWartend: warteschlange.liste().length,
liegestuetzeOffen: liegestuetze.stand(),
ttsStimme: stimmeFehlt.length ? `fehlt: ${stimmeFehlt.join(', ')}` : 'bereit', ttsStimme: stimmeFehlt.length ? `fehlt: ${stimmeFehlt.join(', ')}` : 'bereit',
spikeArmed: spikes.isArmed(), spikeArmed: spikes.isArmed(),
note: state.chatAnonymous note: state.chatAnonymous
@@ -360,6 +371,21 @@ export async function runHub(): Promise<void> {
log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert'); 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; void server;
+68
View File
@@ -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));
}
});
+36
View File
@@ -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) };
}
+4 -1
View File
@@ -31,7 +31,8 @@ export type Einloesung = {
ts: string; ts: string;
}; };
export type AktionsArt = 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts'; export type AktionsArt =
| 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'liegestuetze' | 'nichts';
export type BelohnungsPlan = { export type BelohnungsPlan = {
/** Titel der Belohnung auf Twitch. Gross-/Kleinschreibung egal. */ /** Titel der Belohnung auf Twitch. Gross-/Kleinschreibung egal. */
@@ -43,6 +44,8 @@ export type BelohnungsPlan = {
freigabe?: boolean; freigabe?: boolean;
/** Nur bei `tts`: laengerer Text wird abgelehnt statt gekuerzt. */ /** Nur bei `tts`: laengerer Text wird abgelehnt statt gekuerzt. */
maxZeichen?: number; maxZeichen?: number;
/** Nur bei `liegestuetze`: so viele kommen je Einloesung dazu. Vorgabe 5. */
anzahl?: number;
}; };
export type RewardsConfig = { export type RewardsConfig = {
+8
View File
@@ -108,3 +108,11 @@ test('abgemeldete Zuhoerer bekommen nichts mehr', () => {
assert.equal(n, 1); assert.equal(n, 1);
assert.equal(bus.clients(), 0); 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);
});
+21 -2
View File
@@ -65,6 +65,17 @@ export type StreamEvent = {
startedAt: string | null; 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 = export type OverlayEvent =
| ChatEvent | ChatEvent
| AlertEvent | AlertEvent
@@ -88,10 +99,15 @@ export type OverlayEvent =
} }
/** Alles sofort still. Fuer den Panikschalter. */ /** Alles sofort still. Fuer den Panikschalter. */
| { kind: 'still' } | { kind: 'still' }
| LiegestuetzeEvent
| { kind: 'scene'; scene: string } | { kind: 'scene'; scene: string }
| { kind: 'clear'; userId?: string; messageId?: string } | { kind: 'clear'; userId?: string; messageId?: string }
| { kind: 'beat'; t: number } | { 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 * So viele Nachrichten bekommt ein frisch verbundenes Overlay mit. Mehr
@@ -118,6 +134,7 @@ export function createBus(): Bus {
let stream: StreamEvent = { let stream: StreamEvent = {
kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null, kind: 'stream', live: false, title: '', category: '', viewers: 0, startedAt: null,
}; };
let liegestuetze = 0;
return { return {
publish(ev) { publish(ev) {
@@ -131,6 +148,8 @@ export function createBus(): Bus {
if (alerts.length > KEEP_ALERTS) alerts.shift(); if (alerts.length > KEEP_ALERTS) alerts.shift();
} else if (ev.kind === 'stream') { } else if (ev.kind === 'stream') {
stream = ev; stream = ev;
} else if (ev.kind === 'liegestuetze') {
liegestuetze = ev.offen;
} else if (ev.kind === 'clear') { } else if (ev.kind === 'clear') {
// Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der // Geloeschtes verschwindet auch aus dem Rueckblick, sonst holt der
// naechste Neustart der Browser-Source es zurueck ins Bild. // naechste Neustart der Browser-Source es zurueck ins Bild.
@@ -153,7 +172,7 @@ export function createBus(): Bus {
return () => { listeners.delete(fn); }; return () => { listeners.delete(fn); };
}, },
snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts] }), snapshot: () => ({ kind: 'state', stream, chat: [...chat], alerts: [...alerts], liegestuetze }),
clients: () => listeners.size, clients: () => listeners.size,
}; };
} }
+104 -1
View File
@@ -64,6 +64,8 @@ export const PAGES = [
title: 'Pausenbild', hint: 'Eigene Szene: "Gleich geht es los", Pause, Ende.' }, title: 'Pausenbild', hint: 'Eigene Szene: "Gleich geht es los", Pause, Ende.' },
{ path: '/tts', file: 'tts.html', w: 600, h: 200, { path: '/tts', file: 'tts.html', w: 600, h: 200,
title: 'Vorlesen', hint: 'Liest freigegebene Nachrichten vor. Braucht Ton in OBS.' }, 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; ] as const;
export type OverlayServer = { export type OverlayServer = {
@@ -96,6 +98,12 @@ export type OverlayOptions = {
/** Die fertige WAV zu einer Nachricht, solange sie abholbar ist. */ /** Die fertige WAV zu einer Nachricht, solange sie abholbar ist. */
audio: (id: string) => Buffer | undefined; 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 { 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(/^\/+/, '')); if (/\.(css|js|svg|png|jpg|woff2)$/.test(p)) return sendFile(res, p.replace(/^\/+/, ''));
// ---- Die Browser-Sources und ihr Datenstrom. // ---- 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]; const audioId = /^\/tts\/audio\/([A-Za-z0-9]+)$/.exec(p)?.[1];
if (page || p === '/events' || audioId) { if (page || p === '/events' || audioId) {
if (!zugang.darfOverlay(req, url)) { if (!zugang.darfOverlay(req, url)) {
@@ -276,6 +288,27 @@ async function handle(
JSON.stringify({ wartend: opts.tts.wartend() })); 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') { if (p === '/schluessel/neu' && req.method === 'POST') {
zugang.neuerOverlayKey(); zugang.neuerOverlayKey();
return redirect(res, '/'); return redirect(res, '/');
@@ -351,6 +384,13 @@ function readForm(req: http.IncomingMessage): Promise<URLSearchParams> {
}); });
} }
/** 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 { function redirect(res: http.ServerResponse, to: string): void {
res.writeHead(303, { location: to }); res.writeHead(303, { location: to });
res.end(); res.end();
@@ -427,6 +467,11 @@ const SEITEN_CSS = `
gap: 1rem; padding: .7rem .9rem; border-top: 1px solid #241f35; } gap: 1rem; padding: .7rem .9rem; border-top: 1px solid #241f35; }
.wartend:first-child { border-top: 0; } .wartend:first-child { border-top: 0; }
.wartend .hint { max-width: 60ch; word-break: break-word; } .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; } button.tts { margin-left: .4rem; }
`; `;
@@ -466,6 +511,25 @@ Kanalpunkte zurück, sofern das verbundene Konto das darf.</p>
<span class="hint">„Sofort still" bricht auch ab, was gerade gesprochen wird.</span> <span class="hint">„Sofort still" bricht auch ab, was gerade gesprochen wird.</span>
</div> </div>
<p id="tts-meldung" class="meldung">&nbsp;</p>` : ''; <p id="tts-meldung" class="meldung">&nbsp;</p>` : '';
const lzFeld = opts.liegestuetze ? `
<h2>Liegestütze</h2>
<p class="lead">Jede Einlösung zählt hoch. Hier herunterzählen, wenn sie gemacht
sind, oder den Stand korrigieren. Die Zuschauer sehen jede Änderung.</p>
<div class="lz-stand"><span id="lz-offen">${opts.liegestuetze.stand()}</span><small>offen</small></div>
<div class="gruppe">
<button class="lz" data-um="-10">−10</button>
<button class="lz" data-um="-5">−5</button>
<button class="lz" data-um="-1">−1</button>
<button class="lz" data-um="1">+1</button>
<button class="lz" data-um="5">+5</button>
<button class="lz" data-um="10">+10</button>
</div>
<form class="gruppe" id="lz-form">
<input class="lz" id="lz-wert" type="number" min="0" step="1" inputmode="numeric" placeholder="Anzahl">
<button type="submit" class="lz">setzen</button>
<button type="button" class="lz" id="lz-null">auf 0</button>
</form>
<p id="lz-meldung" class="meldung">&nbsp;</p>` : '';
const base = opts.publicUrl || `http://127.0.0.1:${opts.port}`; const base = opts.publicUrl || `http://127.0.0.1:${opts.port}`;
const rows = PAGES.map((p) => { const rows = PAGES.map((p) => {
const id = `u${p.path.slice(1)}`; const id = `u${p.path.slice(1)}`;
@@ -524,6 +588,8 @@ ${testfeld}
${ttsFeld} ${ttsFeld}
${lzFeld}
<h2>Schlüssel</h2> <h2>Schlüssel</h2>
<form method="post" action="/schluessel/neu"> <form method="post" action="/schluessel/neu">
<button type="submit">Schlüssel neu würfeln</button> <button type="submit">Schlüssel neu würfeln</button>
@@ -565,11 +631,48 @@ ${ttsFeld}
? 'Stream läuft: ' + (ev.stream.title || 'ohne Titel') ? 'Stream läuft: ' + (ev.stream.title || 'ohne Titel')
: 'verbunden - kein Stream'; : 'verbunden - kein Stream';
setzeLive(ev.stream.live); setzeLive(ev.stream.live);
zeigeLiegestuetze(ev.liegestuetze);
} else if (ev.kind === 'stream') { } else if (ev.kind === 'stream') {
setzeLive(ev.live); setzeLive(ev.live);
} else if (ev.kind === 'liegestuetze') {
zeigeLiegestuetze(ev.offen);
} }
}; };
// 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');
}
const lzMeldung = document.getElementById('lz-meldung');
async function lzRuf(was, wert) {
const res = await fetch('/liegestuetze', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ was, wert: String(wert) }),
});
const body = await res.json().catch(() => ({ text: 'Fehler ' + res.status }));
zeigeLiegestuetze(body.offen);
lzMeldung.textContent = body.text || '';
lzMeldung.className = 'meldung' + (res.ok ? ' ok' : ' fehler');
setTimeout(() => { lzMeldung.innerHTML = '&nbsp;'; lzMeldung.className = 'meldung'; }, 4000);
}
for (const b of document.querySelectorAll('button.lz[data-um]')) {
b.addEventListener('click', () => lzRuf('aendern', b.dataset.um));
}
const lzForm = document.getElementById('lz-form');
if (lzForm) {
lzForm.addEventListener('submit', (e) => {
e.preventDefault();
const feld = document.getElementById('lz-wert');
if (feld.value === '') return;
lzRuf('setzen', feld.value);
feld.value = '';
});
document.getElementById('lz-null').addEventListener('click', () => lzRuf('setzen', 0));
}
let sagAb; let sagAb;
async function loese(art) { async function loese(art) {
const res = await fetch('/test', { const res = await fetch('/test', {
+186
View File
@@ -0,0 +1,186 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>abdulive - Liegestütze</title>
<link rel="stylesheet" href="/theme.css">
<style>
/* Offene Liegestütze aus Kanalpunkten.
*
* Eine Einlösung zählt hoch und zeigt kurz, wer es war. Wenn Abdu auf der
* Übersichtsseite herunterzählt, blinkt die Zahl grün - ohne Namen, damit
* eine Korrektur nicht wie eine Einlösung aussieht.
*
* In OBS: Browser-Quelle 420x200. Einstellungen in der Adresse:
* ?verstecken=1 bei 0 ausblenden statt "erledigt" zeigen
* ?titel=... andere Beschriftung als "Liegestütze" */
#karte {
position: absolute;
left: 12px; bottom: 12px;
display: flex; align-items: center; gap: 18px;
padding: 16px 26px 16px 18px;
overflow: hidden;
transition: opacity .5s, transform .5s, box-shadow .4s, border-color .4s;
}
#karte.weg { opacity: 0; transform: translateY(14px); }
.symbol {
width: 58px; height: 58px; flex: none;
display: grid; place-items: center;
border-radius: 14px;
background: linear-gradient(135deg, var(--lila), var(--pink));
box-shadow: 0 6px 18px rgba(169, 112, 255, .45);
}
.symbol svg { width: 36px; height: 36px; }
.text { min-width: 0; }
#titel {
font: 600 13px/1 var(--font-zahl);
letter-spacing: .16em; text-transform: uppercase;
color: var(--schrift-leise);
}
#zahl {
display: block;
font: 800 54px/1 var(--font);
font-variant-numeric: tabular-nums;
letter-spacing: -.02em;
margin-top: 6px;
}
#zahl.erledigt { font-size: 30px; color: var(--gruen); padding: 8px 0 6px; }
/* Einlösung: Karte glüht gold, Zahl springt. */
#karte.mehr { border-color: var(--gold); box-shadow: var(--schatten), 0 0 28px rgba(255, 197, 61, .55); }
#zahl.springen { animation: springen .6s cubic-bezier(.3, 1.6, .5, 1); }
@keyframes springen {
0% { transform: scale(1); }
40% { transform: scale(1.22); color: var(--gold); }
100% { transform: scale(1); }
}
/* Korrektur nach unten: kurz grün, das heißt "gemacht". */
#zahl.weniger { animation: weniger .9s ease-out; }
@keyframes weniger {
0% { color: var(--gruen); transform: translateY(-4px); }
100% { color: inherit; transform: none; }
}
/* "+5 von Name", steigt über der Karte auf. */
#plus {
position: absolute;
left: 12px; bottom: 112px;
max-width: 396px;
padding: 8px 14px;
font: 700 20px/1.1 var(--font);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
opacity: 0;
}
#plus b { color: var(--gold); margin-right: 6px; }
#plus.zeigen { animation: aufsteigen 3.6s ease-out both; }
@keyframes aufsteigen {
0% { opacity: 0; transform: translateY(16px); }
12% { opacity: 1; transform: none; }
80% { opacity: 1; transform: none; }
100% { opacity: 0; transform: translateY(-10px); }
}
</style>
</head>
<body>
<div id="plus" class="glas"></div>
<div id="karte" class="glas">
<div class="symbol" aria-hidden="true">
<!-- Figur im Liegestütz: Kopf, Rücken, Arme, Boden. -->
<svg viewBox="0 0 48 48" fill="none" stroke="#fff" stroke-width="3.4"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="17" r="4" fill="#fff" stroke="none"/>
<path d="M15 21 L41 29"/>
<path d="M17 22 L15 33"/>
<path d="M41 29 L42 33"/>
<path d="M6 37 H44" stroke-width="2.4" opacity=".7"/>
</svg>
</div>
<div class="text">
<div id="titel" class="kontur">Liegestütze</div>
<span id="zahl" class="kontur">0</span>
</div>
</div>
<script src="/client.js"></script>
<script>
(function () {
'use strict';
var karte = document.getElementById('karte');
var zahl = document.getElementById('zahl');
var plus = document.getElementById('plus');
var verstecken = Overlay.param('verstecken', false);
document.getElementById('titel').textContent = Overlay.param('titel', 'Liegestütze');
var angezeigt = null; // was gerade im Bild steht
var lauf = null; // laufendes Hochzählen
var glimmen = null;
function schreibe(n) {
if (n === 0 && !verstecken) {
zahl.textContent = 'erledigt';
zahl.classList.add('erledigt');
} else {
zahl.textContent = Overlay.zahl(n);
zahl.classList.remove('erledigt');
}
karte.classList.toggle('weg', verstecken && n === 0);
}
function nochmal(el, klasse) {
el.classList.remove(klasse);
void el.offsetWidth;
el.classList.add(klasse);
}
/** Zählt sichtbar von der alten zur neuen Zahl hoch. Bei großen Sprüngen
* nicht jede einzelne Zahl, sonst dauert ein Setzen auf 500 ewig. */
function hochzaehlen(von, bis) {
clearInterval(lauf);
var schritte = Math.min(bis - von, 20);
var i = 0;
lauf = setInterval(function () {
i++;
schreibe(i >= schritte ? bis : Math.round(von + (bis - von) * i / schritte));
if (i >= schritte) clearInterval(lauf);
}, 45);
}
function stand(n, einloesung) {
if (typeof n !== 'number') return;
var alt = angezeigt;
angezeigt = n;
// Erster Stand nach dem Verbinden: einfach hinschreiben, ohne Effekt.
if (alt === null || alt === n) { clearInterval(lauf); schreibe(n); return; }
if (n > alt) {
hochzaehlen(alt, n);
nochmal(zahl, 'springen');
karte.classList.add('mehr');
clearTimeout(glimmen);
glimmen = setTimeout(function () { karte.classList.remove('mehr'); }, 2500);
} else {
clearInterval(lauf);
schreibe(n);
nochmal(zahl, 'weniger');
}
if (einloesung && einloesung.name) {
plus.innerHTML = '<b>+' + Overlay.esc(einloesung.dazu) + '</b>' +
'von ' + Overlay.esc(einloesung.name);
nochmal(plus, 'zeigen');
}
}
Overlay.on('state', function (ev) { stand(ev.liegestuetze); });
Overlay.on('liegestuetze', function (ev) {
stand(ev.offen, ev.dazu ? { dazu: ev.dazu, name: ev.name } : null);
});
})();
</script>
</body>
</html>
+4 -1
View File
@@ -130,7 +130,7 @@ loyalty:
# Belohnungen, die hier nicht stehen, werden protokolliert und sonst in Ruhe # Belohnungen, die hier nicht stehen, werden protokolliert und sonst in Ruhe
# gelassen - nicht jede Belohnung geht das Toolkit etwas an. # gelassen - nicht jede Belohnung geht das Toolkit etwas an.
# #
# Aktionen: konfetti | emote-regen | einblendung | tts | nichts # Aktionen: konfetti | emote-regen | einblendung | tts | liegestuetze | nichts
rewards: rewards:
enabled: false # aus, bis hier bewusst Belohnungen stehen enabled: false # aus, bis hier bewusst Belohnungen stehen
belohnungen: [] belohnungen: []
@@ -141,6 +141,9 @@ rewards:
# - titel: Nachricht vorlesen # - titel: Nachricht vorlesen
# aktion: tts # aktion: tts
# maxZeichen: 150 # maxZeichen: 150
# - titel: 5 Liegestütze
# aktion: liegestuetze
# anzahl: 5 # so viele kommen je Einloesung dazu
tts: tts:
# Vorgabe ist die Freigabe von Hand, und das aus gutem Grund: Kanalpunkte # Vorgabe ist die Freigabe von Hand, und das aus gutem Grund: Kanalpunkte
# sammeln sich beim Zugucken von allein an und sind damit kaum eine # sammeln sich beim Zugucken von allein an und sind damit kaum eine
+4 -4
View File
@@ -60,8 +60,8 @@ export type Config = {
enabled: boolean; enabled: boolean;
belohnungen: Array<{ belohnungen: Array<{
titel?: string; id?: string; titel?: string; id?: string;
aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts'; aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'liegestuetze' | 'nichts';
freigabe?: boolean; maxZeichen?: number; freigabe?: boolean; maxZeichen?: number; anzahl?: number;
}>; }>;
tts: { tts: {
freigabe: boolean; maxZeichen: number; freigabe: boolean; maxZeichen: number;
@@ -135,8 +135,8 @@ const DEFAULTS = {
enabled: false, enabled: false,
belohnungen: [] as Array<{ belohnungen: [] as Array<{
titel?: string; id?: string; titel?: string; id?: string;
aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'nichts'; aktion: 'konfetti' | 'emote-regen' | 'einblendung' | 'tts' | 'liegestuetze' | 'nichts';
freigabe?: boolean; maxZeichen?: number; freigabe?: boolean; maxZeichen?: number; anzahl?: number;
}>, }>,
tts: { tts: {
// Vorgabe ist die Freigabe durch Abdu. Kanalpunkte sammeln sich beim // Vorgabe ist die Freigabe durch Abdu. Kanalpunkte sammeln sich beim
+8
View File
@@ -0,0 +1,8 @@
-- Zaehler, die einen Neustart ueberleben muessen. Erster Fall: offene
-- Liegestuetze aus Kanalpunkten. Wer 2000 Punkte ausgegeben hat, will nicht
-- erleben, dass ein Update des Hubs seine Liegestuetze verschluckt.
CREATE TABLE zaehler (
name TEXT PRIMARY KEY,
wert INTEGER NOT NULL,
geaendert TEXT NOT NULL
);