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,
} 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<void> {
// ---- 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<void> {
});
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<void> {
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<void> {
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;
+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;
};
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 = {