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:
1 parent
5622499877
commit
eb9eab949e
11 files changed
+469
-9
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user