Einzelne Gruppenmitglieder, Austritt per Redesign, Ladebalken beim Abgleich

Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und bisher liess sich
nur die obere bedienen. Jetzt beide:

Ein Spieler kann seinen Anteil redesignen und steigt damit aus - er bekommt
einen eigenen CC, die Gruppe laeuft unberuehrt weiter. Fuer ihn ist es ein
Tausch wie jeder andere: der Gruppenplatz geht weg, der eigene CC kommt an
seine Stelle, es kommt keiner hinzu. Wohin er gewechselt ist, bleibt an der
Mitgliedschaft festgehalten und steht bei den frueheren Nutzern. Das Redesign
der ganzen Gruppe, bei dem alle mitwandern, gibt es unveraendert daneben.

Ebenso koennen einzelne Nutzer ausfallen, ohne die uebrigen zu beruehren: bei
einem Trio kann einer im Rollenspiel sterben, waehrend die anderen
weiterspielen, und ebenso koennen alle nacheinander wegfallen. Grund und
Begruendung haengen jetzt auch an der Mitgliedschaft. Die Rueckkehr folgt
denselben Regeln wie beim eigenen CC - geputzt gratis, entzogen gegen
Guthaben, gestorben nur durch einen Administrator mit Begruendung. Wer
ausgefallen ist, sieht seinen CC weiter auf der Spielerseite; sonst gaebe es
nichts, worueber er zurueckkehren koennte. Ein freiwilliger Austritt bleibt
davon getrennt: dort gibt es nichts zurueckzuholen, ein Wiedereintritt kostet
neu.

Der GExtension-Abgleich hat denselben Ladebalken wie der Forumslauf bekommen,
fuer alle Spieler wie fuer einzelne, samt Protokoll. Beide teilen sich jetzt
dieselbe Anzeige.

Dabei aufgefallen: Zeitplan und Handklick konnten denselben Abgleich doppelt
starten und haetten dieselben Profile zweimal abgerufen. Die Sperre sitzt
jetzt im Lauf selbst statt im Starter, damit sie fuer beide Wege gilt.
This commit is contained in:
2026-09-21 12:50:40 +02:00
parent dbde5b7d11
commit 029d380abf
17 changed files with 1368 additions and 172 deletions
@@ -0,0 +1,20 @@
-- Bei einem Gruppen-CC kann ein einzelner Spieler ausfallen, waehrend die
-- uebrigen weiterspielen: geputzt, entzogen oder im Rollenspiel gestorben.
-- Der Grund haengt deshalb auch an der Mitgliedschaft, nicht nur am CC.
ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "inactiveReason" "InactiveReason";
ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "inactiveNote" TEXT;
-- Wer seinen Anteil am Gruppen-CC redesignt, steigt damit aus und bekommt
-- einen eigenen CC. Wohin er gewechselt ist, bleibt nachvollziehbar.
ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "successorId" INTEGER;
CREATE INDEX IF NOT EXISTS "CharacterMember_successorId_idx" ON "CharacterMember"("successorId");
DO $$
BEGIN
ALTER TABLE "CharacterMember"
ADD CONSTRAINT "CharacterMember_successorId_fkey"
FOREIGN KEY ("successorId") REFERENCES "Character"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
+18 -2
View File
@@ -241,6 +241,8 @@ model Character {
members CharacterMember[]
/// Beitritte, für die dieser CC aufgegeben wurde.
givenUpFor CharacterMember[] @relation("MemberPredecessor")
/// Austritte, aus denen dieser CC hervorgegangen ist.
grewOutOf CharacterMember[] @relation("MemberSuccessor")
createdById Int?
createdBy User? @relation("CharacterCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
@@ -426,10 +428,18 @@ model CharacterMember {
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
joinedAt DateTime @default(now())
/// Gesetzt, sobald der Spieler ausgestiegen ist. Der Eintrag bleibt stehen,
/// damit nachvollziehbar ist, wer den CC wann genutzt hat.
/// Gesetzt, sobald der Spieler den CC nicht mehr nutzt - ob freiwillig
/// ausgetreten, geputzt oder im Rollenspiel gestorben. Der Eintrag bleibt
/// stehen, damit nachvollziehbar ist, wer den CC wann genutzt hat.
leftAt DateTime?
/// Warum er nicht mehr dabei ist. Leer bei einem einfachen Austritt; bei
/// einem Gruppen-CC kann ein einzelner Spieler sterben oder geputzt werden,
/// während die übrigen weiterspielen. Die Rückkehr folgt denselben Regeln
/// wie bei einem eigenen CC.
inactiveReason InactiveReason?
inactiveNote String?
/// Womit der Platz bezahlt wurde. Leer beim Ersteller, dessen Buchung am
/// CC selbst hängt.
paidWith CreditKind?
@@ -441,6 +451,12 @@ model CharacterMember {
predecessorId Int?
predecessor Character? @relation("MemberPredecessor", fields: [predecessorId], references: [id], onDelete: SetNull)
/// Der CC, in den der Spieler von hier aus gewechselt ist. Gesetzt, wenn er
/// seinen Anteil am Gruppen-CC redesignt hat und damit ausgestiegen ist -
/// die Gruppe läuft dabei unberührt weiter.
successorId Int?
successor Character? @relation("MemberSuccessor", fields: [successorId], references: [id], onDelete: SetNull)
createdById Int?
/// Ein Spieler kann demselben CC nicht zweimal angehören.
+108
View File
@@ -2,6 +2,8 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { CreditKind, InactiveReason } from '@prisma/client';
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
import { forbidden } from '../errors.js';
import { prisma } from '../db.js';
import {
addMember,
createCharacter,
@@ -14,6 +16,9 @@ import {
redesignCharacter,
revertCharacter,
updateCharacter,
deactivateMember,
reactivateMember,
redesignMembership,
} from '../services/characters.js';
import { parseThreadId } from '../lib/forum.js';
import { linkThreadToCharacter } from '../services/forum.js';
@@ -163,6 +168,109 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
);
});
/**
* Der Anteil eines Spielers an einem Gruppen-CC wird redesignt: er steigt
* aus, bekommt einen eigenen CC, die Gruppe läuft weiter.
*/
app.post(
'/api/characters/:id/members/:playerId/redesign',
{ preHandler: requireEditor },
async (request) => {
const params = parseWith(
z.object({
id: z.coerce.number().int().positive(),
playerId: z.coerce.number().int().positive(),
}),
request.params,
);
const body = parseWith(
z.object({
name: z.string().trim().min(1).max(160),
loreText: z.string().trim().max(5000).optional(),
useKind: z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]),
note: optionalNote,
}),
request.body,
);
return redesignMembership(params.id, params.playerId, body, contextOf(request));
},
);
/** Ein einzelnes Mitglied fällt aus, der CC bleibt bestehen. */
app.post(
'/api/characters/:id/members/:playerId/deactivate',
{ preHandler: requireEditor },
async (request) => {
const params = parseWith(
z.object({
id: z.coerce.number().int().positive(),
playerId: z.coerce.number().int().positive(),
}),
request.params,
);
const body = parseWith(
z.object({
reason: z.enum([
InactiveReason.PLAYER_INACTIVITY,
InactiveReason.OTHER,
InactiveReason.NO_FREE_CLAIM,
InactiveReason.DEAD,
]),
note: optionalNote,
}),
request.body,
);
// Ein Tod im Rollenspiel ist eine Ausnahmeentscheidung, genau wie beim
// ganzen CC - und bleibt deshalb Administratoren vorbehalten.
if (body.reason === InactiveReason.DEAD && request.user?.role !== 'ADMIN') {
throw forbidden('Einen Tod im Rollenspiel trägt nur ein Administrator ein');
}
return deactivateMember(
params.id,
params.playerId,
body.reason,
contextOf(request),
body.note,
);
},
);
/** Rückkehr eines ausgefallenen Mitglieds. */
app.post(
'/api/characters/:id/members/:playerId/reactivate',
{ preHandler: requireEditor },
async (request) => {
const params = parseWith(
z.object({
id: z.coerce.number().int().positive(),
playerId: z.coerce.number().int().positive(),
}),
request.params,
);
const body = parseWith(
z.object({
useKind: reviveKind.optional(),
reason: z.string().trim().max(500).optional(),
}),
request.body ?? {},
);
// Wer im Rollenspiel gestorben ist, kehrt nur durch einen Administrator
// zurück. Geprüft wird am tatsächlichen Grund, nicht an der Eingabe.
const mitglied = await prisma.characterMember.findUnique({
where: { characterId_playerId: { characterId: params.id, playerId: params.playerId } },
select: { inactiveReason: true },
});
if (mitglied?.inactiveReason === InactiveReason.DEAD && request.user?.role !== 'ADMIN') {
throw forbidden('Über die Rückkehr eines Gestorbenen entscheidet ein Administrator');
}
return reactivateMember(params.id, params.playerId, body, contextOf(request));
},
);
app.delete('/api/characters/:id/members/:playerId', { preHandler: requireEditor }, async (request) => {
const params = parseWith(
z.object({
+8 -2
View File
@@ -4,7 +4,9 @@ import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
import { notFound } from '../errors.js';
import { prisma } from '../db.js';
import {
getScrapeState,
knownSteamIds,
startScrape,
steamIdsOf,
listUnmatched,
resolveUnmatched,
@@ -30,7 +32,11 @@ export async function scraperRoutes(app: FastifyInstance): Promise<void> {
? all.filter((entry) => body.playerIds?.includes(entry.playerId))
: all;
return scrapePlayers(targets, contextOf(request));
return startScrape(targets, contextOf(request));
});
app.get('/api/scrape/status', { preHandler: requireAuth }, async () => {
return getScrapeState();
});
/**
@@ -49,7 +55,7 @@ export async function scraperRoutes(app: FastifyInstance): Promise<void> {
throw notFound('SteamID des Spielers');
}
return scrapePlayers(
return startScrape(
steamIds.map((steamId) => ({ playerId: id, steamId })),
contextOf(request),
);
+296 -3
View File
@@ -524,10 +524,19 @@ export async function revertCharacter(
/**
* Die CCs eines Spielers. Maßgeblich ist die Mitgliedschaft, nicht wer den CC
* angelegt hat - sonst fehlten Gruppen-CCs, denen der Spieler beigetreten ist.
*
* Mit dabei sind auch CCs, bei denen seine Mitgliedschaft ruht: wer geputzt
* wurde oder im Rollenspiel gestorben ist, kann zurückkehren und muss den CC
* dafür sehen. Wer freiwillig ausgetreten ist, sieht ihn nicht mehr - dort
* gibt es nichts zurückzuholen, ein Wiedereintritt kostet neu.
*/
export async function listCharacters(playerId: number) {
return prisma.character.findMany({
where: { members: { some: { playerId, leftAt: null } } },
where: {
members: {
some: { playerId, OR: [{ leftAt: null }, { inactiveReason: { not: null } }] },
},
},
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
include: {
predecessor: { select: { id: true, name: true } },
@@ -539,7 +548,10 @@ export async function listCharacters(playerId: number) {
},
members: {
orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }],
include: { player: { select: { id: true, displayName: true } } },
include: {
player: { select: { id: true, displayName: true } },
successor: { select: { id: true, name: true } },
},
},
},
});
@@ -800,6 +812,283 @@ export async function addMember(
return { character, balances, overdrawn: isOverdrawn(balances), memberCount };
}
/**
* Redesignt den Anteil eines Spielers an einem Gruppen-CC.
*
* Er steigt damit aus und bekommt einen eigenen CC an dessen Stelle; die
* Gruppe läuft unberührt weiter. Für ihn ist es ein Redesign wie jedes
* andere: ein Platz wird getauscht, keiner kommt hinzu.
*
* Ist er der letzte Nutzer, gibt es keine Gruppe mehr, die weiterlaufen
* könnte - dann ist es das gewöhnliche Redesign des ganzen CCs, und der läuft
* über redesignCharacter(). Ein Gruppen-Redesign, bei dem alle mitwandern,
* ebenfalls: das ist die Sache des ganzen CCs, nicht die eines Mitglieds.
*/
export async function redesignMembership(
characterId: number,
playerId: number,
input: {
name: string;
loreText?: string | undefined;
useKind: RedesignKind;
note?: string | undefined;
},
ctx: ActionContext,
): Promise<CharacterResult & { leftGroup: boolean }> {
const gruppe = await loadCharacter(characterId);
if (gruppe.status !== CharacterStatus.ACTIVE) {
throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden');
}
if (!REDESIGN_KINDS.includes(input.useKind)) {
throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben');
}
const name = input.name.trim();
if (!name) throw badRequest('NO_NAME', 'Für den neuen CC wird ein Name gebraucht');
const mitglied = await prisma.characterMember.findUnique({
where: { characterId_playerId: { characterId, playerId } },
});
if (!mitglied || mitglied.leftAt !== null) {
throw notFound('Mitgliedschaft');
}
const verbleibend = await prisma.characterMember.count({
where: { characterId, leftAt: null },
});
if (verbleibend <= 1) {
throw conflict(
'LAST_MEMBER',
'Dieser Spieler ist der letzte Nutzer. Dann ist es ein Redesign des ganzen CCs, kein Austritt.',
);
}
const neuerCC = await prisma.$transaction(async (tx) => {
const erstellt = await tx.character.create({
data: {
playerId,
name,
loreText: input.loreText ?? null,
status: CharacterStatus.ACTIVE,
// Eine eigene Linie: der Gruppen-CC lebt weiter, hier beginnt ein
// neuer Strang. Woher er kommt, steht an der Mitgliedschaft.
predecessorId: gruppe.id,
createdById: ctx.actorId,
members: { create: [{ playerId, paidWith: input.useKind, createdById: ctx.actorId }] },
},
});
// Kein Grund für eine Rückkehr: sein Platz wurde getauscht, nicht
// entzogen. Wohin er gegangen ist, steht im Nachfolger - und genau daran
// hängt auch, dass der Gruppen-CC nicht länger als seiner gilt.
await tx.characterMember.update({
where: { id: mitglied.id },
data: {
leftAt: new Date(),
successorId: erstellt.id,
note: input.note ?? `Anteil redesignt zu "${erstellt.name}"`,
},
});
await tx.creditLedger.create({
data: {
playerId,
kind: input.useKind,
amount: -1,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId: erstellt.id,
note: input.note ?? `Redesign aus "${gruppe.name}" heraus: ${erstellt.name}`,
createdById: ctx.actorId,
},
});
await assertSlotsReichen(tx, playerId, 'Dieses Redesign');
await recordAudit(tx, ctx, {
action: 'character.membership_redesigned',
targetType: 'character',
targetId: erstellt.id,
details: {
playerId,
from: gruppe.name,
fromId: gruppe.id,
to: erstellt.name,
useKind: input.useKind,
remaining: verbleibend - 1,
},
});
return erstellt;
});
const balances = await getBalances(playerId);
return { character: neuerCC, balances, overdrawn: isOverdrawn(balances), leftGroup: true };
}
/**
* Setzt eine einzelne Mitgliedschaft inaktiv - der CC selbst bleibt, wie er
* ist. Bei einem Gruppen-CC kann genau ein Spieler wegfallen, ohne dass die
* anderen etwas davon merken: einer stirbt im Rollenspiel, einer wird
* geputzt, die übrigen spielen weiter.
*
* Für die Rückkehr gelten dieselben Regeln wie bei einem eigenen CC - gratis,
* bezahlt oder nur durch einen Administrator.
*/
export async function deactivateMember(
characterId: number,
playerId: number,
reason: InactiveReason,
ctx: ActionContext,
note?: string,
): Promise<{ memberCount: number }> {
if (reason === InactiveReason.REWORKED) {
throw badRequest(
'USE_REDESIGN',
'Ein Rework entsteht über das Redesign des Anteils, nicht über eine Deaktivierung',
);
}
if (reason === InactiveReason.DEAD && !note?.trim()) {
throw badRequest('NEED_NOTE', 'Für einen Tod im Rollenspiel wird eine Begründung gebraucht');
}
const mitglied = await prisma.characterMember.findUnique({
where: { characterId_playerId: { characterId, playerId } },
include: { character: { select: { name: true } } },
});
if (!mitglied || mitglied.leftAt !== null) {
throw notFound('Mitgliedschaft');
}
const verbleibend = await prisma.characterMember.count({
where: { characterId, leftAt: null },
});
if (verbleibend <= 1) {
throw conflict(
'LAST_MEMBER',
'Das ist der letzte Nutzer dieses CCs. Dann ist der ganze CC betroffen, nicht nur eine Mitgliedschaft.',
);
}
await prisma.$transaction(async (tx) => {
await tx.characterMember.update({
where: { id: mitglied.id },
data: {
leftAt: new Date(),
inactiveReason: reason,
inactiveNote: note?.trim() || null,
},
});
await recordAudit(tx, ctx, {
action: 'character.member_deactivated',
targetType: 'character',
targetId: characterId,
details: { playerId, name: mitglied.character.name, reason, note: note ?? null },
});
});
return { memberCount: verbleibend - 1 };
}
/**
* Holt eine einzelne Mitgliedschaft zurück. Die Regeln sind dieselben wie bei
* einem eigenen CC: was unverändert weiterbesteht, kommt gratis zurück; was
* ersetzt oder entzogen wurde, kostet; und über einen im Rollenspiel
* Gestorbenen entscheidet ein Administrator.
*/
export async function reactivateMember(
characterId: number,
playerId: number,
input: { useKind?: ReviveKind | undefined; reason?: string | undefined },
ctx: ActionContext,
): Promise<{ memberCount: number }> {
const gruppe = await loadCharacter(characterId);
if (gruppe.status !== CharacterStatus.ACTIVE) {
throw conflict('NOT_ACTIVE', 'Dieser CC ist selbst inaktiv - erst ihn zurückholen');
}
const mitglied = await prisma.characterMember.findUnique({
where: { characterId_playerId: { characterId, playerId } },
});
if (!mitglied) throw notFound('Mitgliedschaft');
if (mitglied.leftAt === null) {
throw conflict('ALREADY_MEMBER', 'Dieser Spieler nutzt den CC bereits');
}
const grund = mitglied.inactiveReason;
// Dass hierfür ein Administrator nötig ist, sichert die Route ab - so wie
// bei einem im Rollenspiel gestorbenen CC. Hier steht nur, was der Vorgang
// selbst verlangt.
if (grund === InactiveReason.DEAD && !input.reason?.trim()) {
throw badRequest(
'NEED_REASON',
'Für die Rückkehr eines Gestorbenen wird eine Begründung gebraucht',
);
}
// Ein Austritt ohne Grund war freiwillig: der Platz ist weg und muss neu
// bezahlt werden. Dafür gibt es den gewöhnlichen Beitritt.
if (grund === null) {
throw conflict(
'WAS_VOLUNTARY',
'Dieser Spieler ist ausgetreten, nicht ausgefallen. Eine Rückkehr ist ein neuer Beitritt und kostet ein Guthaben.',
);
}
const kostenpflichtig =
grund === InactiveReason.REWORKED || grund === InactiveReason.NO_FREE_CLAIM;
if (kostenpflichtig && input.useKind === undefined) {
throw badRequest('NEED_KIND', 'Diese Rückkehr kostet ein Guthaben - bitte angeben, welches');
}
if (input.useKind !== undefined && !REVIVE_KINDS.includes(input.useKind)) {
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art');
}
await prisma.$transaction(async (tx) => {
await tx.characterMember.update({
where: { id: mitglied.id },
data: { leftAt: null, inactiveReason: null, inactiveNote: null },
});
if (input.useKind !== undefined) {
await tx.creditLedger.create({
data: {
playerId,
kind: input.useKind,
amount: -1,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId,
note: input.reason ?? `Rückkehr zu "${gruppe.name}"`,
createdById: ctx.actorId,
},
});
}
await assertSlotsReichen(tx, playerId, 'Die Rückkehr');
await recordAudit(tx, ctx, {
action: 'character.member_reactivated',
targetType: 'character',
targetId: characterId,
details: {
playerId,
name: gruppe.name,
previousReason: grund,
useKind: input.useKind ?? null,
reason: input.reason ?? null,
},
});
});
const memberCount = await prisma.characterMember.count({
where: { characterId, leftAt: null },
});
return { memberCount };
}
/**
* Trägt einen Spieler wieder aus. Das verbrauchte Guthaben bleibt verbraucht -
* wer aussteigt, hat den CC bis dahin genutzt. Für eine Fehleingabe ist die
@@ -851,6 +1140,10 @@ export async function listMembers(characterId: number) {
return prisma.characterMember.findMany({
where: { characterId },
orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }],
include: { player: { select: { id: true, displayName: true, steamId: true } } },
include: {
player: { select: { id: true, displayName: true, steamId: true } },
successor: { select: { id: true, name: true } },
predecessor: { select: { id: true, name: true } },
},
});
}
+42 -23
View File
@@ -165,34 +165,22 @@ export function getScanState(): ScanState {
return { ...scanState };
}
/** Stößt einen Lauf an und kehrt sofort zurück. */
/**
* Stößt einen Lauf an und kehrt sofort zurück.
*
* Ob jemand hier klickt oder der Zeitplan anstößt, macht keinen Unterschied:
* die Sperre gegen zwei gleichzeitige Läufe sitzt in scanForum selbst. Zwei
* Läufe nebeneinander würden dieselben Seiten doppelt abrufen - und das
* Forum bremst schon bei einem.
*/
export function startScan(ctx: ActionContext): ScanState {
if (scanState.running) {
throw conflict('SCAN_RUNNING', 'Es läuft bereits ein Abgleich');
}
scanState.running = true;
scanState.startedAt = new Date().toISOString();
scanState.finishedAt = null;
scanState.currentBoard = null;
scanState.pagesRead = 0;
scanState.totalPages = 0;
scanState.log = [];
scanState.summary = null;
scanState.error = null;
void scanForum(ctx)
.then((summary) => {
scanState.summary = summary;
})
.catch((error: unknown) => {
scanState.error = error instanceof Error ? error.message : 'Abgleich fehlgeschlagen';
})
.finally(() => {
scanState.running = false;
scanState.currentBoard = null;
scanState.finishedAt = new Date().toISOString();
});
void scanForum(ctx).catch(() => {
// Der Fehler steht im Zustand; hier gibt es niemanden, der ihn erwartet.
});
return getScanState();
}
@@ -203,6 +191,35 @@ export function startScan(ctx: ActionContext): ScanState {
* eine Buchung abzuleiten. Jeder neue Thread wartet auf eine Entscheidung.
*/
export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
if (scanState.running) {
throw conflict('SCAN_RUNNING', 'Es läuft bereits ein Abgleich');
}
// Ab hier gilt der Lauf als angefangen. Der Block läuft bis zum ersten
// await am Stück, deshalb kann sich kein zweiter dazwischenschieben.
scanState.running = true;
scanState.startedAt = new Date().toISOString();
scanState.finishedAt = null;
scanState.currentBoard = null;
scanState.pagesRead = 0;
scanState.totalPages = 0;
scanState.log = [];
scanState.summary = null;
scanState.error = null;
try {
return await scanIntern(ctx);
} catch (error) {
scanState.error = error instanceof Error ? error.message : 'Abgleich fehlgeschlagen';
throw error;
} finally {
scanState.running = false;
scanState.currentBoard = null;
scanState.finishedAt = new Date().toISOString();
}
}
async function scanIntern(ctx: ActionContext): Promise<ForumScanSummary> {
const summary: ForumScanSummary = {
scannedBoards: 0,
scannedPages: 0,
@@ -411,6 +428,8 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
: 'Keine neuen Anträge',
);
scanState.summary = summary;
await recordAudit(prisma, ctx, {
action: 'forum.scan',
targetType: 'forum',
+134 -5
View File
@@ -1,7 +1,7 @@
import { ImportStatus, type Prisma } from '@prisma/client';
import { prisma } from '../db.js';
import { env } from '../env.js';
import { badRequest } from '../errors.js';
import { badRequest, conflict } from '../errors.js';
import {
classifyPackage,
fetchProfile,
@@ -81,6 +81,73 @@ export interface ScrapeSummary {
// in die Quere kommen.
const externalIdFor = (packageBoughtId: number) => `gext:${packageBoughtId}`;
export interface ScrapeState {
running: boolean;
startedAt: string | null;
finishedAt: string | null;
/// Wessen Profil gerade gelesen wird.
currentPlayer: string | null;
playersRead: number;
totalPlayers: number;
log: string[];
summary: ScrapeSummary | null;
error: string | null;
}
/**
* Ein Abgleich über alle Spieler geht über dutzende Profile, zwischen denen
* bewusst gewartet wird - das überlebt keine HTTP-Anfrage. Deshalb läuft er
* im Hintergrund, und die Oberfläche fragt den Stand ab.
*/
const scrapeState: ScrapeState = {
running: false,
startedAt: null,
finishedAt: null,
currentPlayer: null,
playersRead: 0,
totalPlayers: 0,
log: [],
summary: null,
error: null,
};
const MAX_LOG_LINES = 200;
function protokoll(zeile: string): void {
scrapeState.log.push(zeile);
if (scrapeState.log.length > MAX_LOG_LINES) scrapeState.log.shift();
}
export function getScrapeState(): ScrapeState {
return { ...scrapeState };
}
/**
* Stößt einen Abgleich an und kehrt sofort zurück.
*
* Ob jemand hier klickt oder der Zeitplan anstößt, macht keinen Unterschied:
* die Sperre gegen zwei gleichzeitige Läufe sitzt in scrapePlayers selbst.
* Zwei Läufe nebeneinander würden dieselben Profile doppelt abrufen und den
* fremden Server ohne Not belasten.
*/
export function startScrape(
targets: { playerId: number; steamId: string }[],
ctx: ActionContext,
): ScrapeState {
if (scrapeState.running) {
throw conflict('SCRAPE_RUNNING', 'Es läuft bereits ein Abgleich');
}
if (targets.length === 0) {
throw badRequest('NO_TARGETS', 'Es gibt niemanden abzugleichen');
}
void scrapePlayers(targets, ctx).catch(() => {
// Der Fehler steht im Zustand; hier gibt es niemanden, der ihn erwartet.
});
return getScrapeState();
}
/**
* Holt die Profile der übergebenen Spieler und legt daraus einen Import an,
* der wie ein Datei-Import in der Vorschau bestätigt wird. Es wird nichts
@@ -89,6 +156,38 @@ const externalIdFor = (packageBoughtId: number) => `gext:${packageBoughtId}`;
export async function scrapePlayers(
targets: { playerId: number; steamId: string }[],
ctx: ActionContext,
): Promise<ScrapeSummary> {
if (scrapeState.running) {
throw conflict('SCRAPE_RUNNING', 'Es läuft bereits ein Abgleich');
}
// Ab hier gilt der Lauf als angefangen. Der Block läuft bis zum ersten
// await am Stück, deshalb kann sich kein zweiter dazwischenschieben.
scrapeState.running = true;
scrapeState.startedAt = new Date().toISOString();
scrapeState.finishedAt = null;
scrapeState.currentPlayer = null;
scrapeState.playersRead = 0;
scrapeState.totalPlayers = targets.length;
scrapeState.log = [];
scrapeState.summary = null;
scrapeState.error = null;
try {
return await scrapeIntern(targets, ctx);
} catch (error) {
scrapeState.error = error instanceof Error ? error.message : 'Abgleich fehlgeschlagen';
throw error;
} finally {
scrapeState.running = false;
scrapeState.currentPlayer = null;
scrapeState.finishedAt = new Date().toISOString();
}
}
async function scrapeIntern(
targets: { playerId: number; steamId: string }[],
ctx: ActionContext,
): Promise<ScrapeSummary> {
const summary: ScrapeSummary = {
importId: null,
@@ -117,16 +216,30 @@ export async function scrapePlayers(
});
const alreadyPending = new Set(pending.map((row) => row.externalId as string));
// Die Namen einmal holen, damit das Protokoll lesbar ist statt SteamIDs
// aneinanderzureihen.
const namen = new Map(
(
await prisma.player.findMany({
where: { id: { in: [...new Set(targets.map((t) => t.playerId))] } },
select: { id: true, displayName: true },
})
).map((p) => [p.id, p.displayName]),
);
for (const target of targets) {
const name = namen.get(target.playerId) ?? target.steamId;
scrapeState.currentPlayer = name;
let profile: ScrapedProfile;
try {
profile = await fetchProfile(target.steamId, { baseUrl: env.GEXTENSION_BASE_URL });
summary.scannedPlayers += 1;
} catch (error) {
summary.failedPlayers.push({
steamId: target.steamId,
reason: error instanceof Error ? error.message : 'Unbekannter Fehler',
});
const grund = error instanceof Error ? error.message : 'Unbekannter Fehler';
summary.failedPlayers.push({ steamId: target.steamId, reason: grund });
scrapeState.playersRead += 1;
protokoll(`${name}: nicht abrufbar (${grund})`);
continue;
}
@@ -204,8 +317,22 @@ export async function scrapePlayers(
where: { id: target.playerId },
data: { gextensionSyncedAt: new Date() },
});
scrapeState.playersRead += 1;
protokoll(
`${name}: ${profile.packages.length} ${
profile.packages.length === 1 ? 'Eintrag' : 'Einträge'
} gelesen`,
);
}
scrapeState.currentPlayer = null;
protokoll(
summary.newRows > 0
? `${summary.newRows} neue Pakete warten auf die Bestätigung`
: 'Keine neuen Pakete',
);
if (rows.length > 0) {
const created = await prisma.import.create({
data: {
@@ -220,6 +347,8 @@ export async function scrapePlayers(
summary.importId = created.id;
}
scrapeState.summary = summary;
await recordAudit(prisma, ctx, {
action: 'scrape.run',
targetType: 'import',
+245
View File
@@ -440,3 +440,248 @@ describe('Slots: ein CC-Paket, ein gleichzeitig aktiver CC', () => {
);
});
});
describe('Aus einem Gruppen-CC herausredesignen', () => {
/** Ein Gruppen-CC mit drei Nutzern, jeder mit eigenem Paket bezahlt. */
async function dreierGruppe(praefix: string) {
const chef = await spielerMitPaket(`${praefix}-Chef`);
const zwei = await spielerMitPaket(`${praefix}-Zwei`);
const drei = await spielerMitPaket(`${praefix}-Drei`);
const { character } = await characters.createCharacter(chef, { name: `${praefix}-Bande` }, ctx);
await characters.addMember(character.id, zwei, { useKind: 'CC' }, ctx);
await characters.addMember(character.id, drei, { useKind: 'CC' }, ctx);
return { chef, zwei, drei, character };
}
it('lässt ein Mitglied aussteigen, ohne die Gruppe anzutasten', async () => {
const { zwei, drei, chef, character } = await dreierGruppe('Austritt');
const ergebnis = await characters.redesignMembership(
character.id,
zwei,
{ name: 'Eigener Weg', useKind: 'REDESIGN_PAID' },
ctx,
);
assert.equal(ergebnis.character.name, 'Eigener Weg');
assert.equal(ergebnis.character.status, 'ACTIVE');
assert.equal(ergebnis.balances.REDESIGN_PAID, 0, 'das Redesign ist verbraucht');
// Die Gruppe läuft unberührt weiter.
const gruppe = await db.prisma.character.findUniqueOrThrow({ where: { id: character.id } });
assert.equal(gruppe.status, 'ACTIVE');
const dabei = await db.prisma.characterMember.findMany({
where: { characterId: character.id, leftAt: null },
select: { playerId: true },
});
assert.deepEqual(
dabei.map((m) => m.playerId).sort((a, b) => a - b),
[chef, drei].sort((a, b) => a - b),
'die beiden anderen bleiben',
);
});
it('hält fest, wohin der Ausgestiegene gewechselt ist', async () => {
const { zwei, character } = await dreierGruppe('Spur');
const ergebnis = await characters.redesignMembership(
character.id,
zwei,
{ name: 'Nachfolger', useKind: 'REDESIGN_PAID' },
ctx,
);
const mitglied = await db.prisma.characterMember.findFirstOrThrow({
where: { characterId: character.id, playerId: zwei },
});
assert.equal(mitglied.successorId, ergebnis.character.id);
assert.equal(
mitglied.inactiveReason,
null,
'sein Platz wurde getauscht, nicht entzogen - es gibt nichts zurückzuholen',
);
assert.equal(ergebnis.character.predecessorId, character.id, 'die Herkunft bleibt sichtbar');
});
it('erschleicht dabei keinen Platz', async () => {
const { zwei, character } = await dreierGruppe('Platz');
await characters.redesignMembership(
character.id,
zwei,
{ name: 'Getauscht', useKind: 'REDESIGN_PAID' },
ctx,
);
const aktive = (await characters.listCharacters(zwei)).filter((c) => c.status === 'ACTIVE');
assert.deepEqual(
aktive.map((c) => c.name),
['Getauscht'],
'ein Paket, ein aktiver CC - der Gruppenplatz ist dafür weg',
);
});
it('weist den letzten Nutzer auf das gewöhnliche Redesign hin', async () => {
const allein = await spielerMitPaket('Einzelgänger');
const { character } = await characters.createCharacter(allein, { name: 'Solo' }, ctx);
await assert.rejects(
() =>
characters.redesignMembership(
character.id,
allein,
{ name: 'Solo II', useKind: 'REDESIGN_PAID' },
ctx,
),
(error: Error & { code?: string }) => error.code === 'LAST_MEMBER',
);
});
it('lässt das Redesign der ganzen Gruppe unberührt weiter zu', async () => {
const { chef, zwei, drei, character } = await dreierGruppe('Gemeinsam');
const neu = await characters.redesignCharacter(
character.id,
{ name: 'Die Bande 2.0', useKind: 'REDESIGN_PAID' },
ctx,
);
const dabei = await db.prisma.characterMember.findMany({
where: { characterId: neu.character.id, leftAt: null },
select: { playerId: true },
});
assert.deepEqual(
dabei.map((m) => m.playerId).sort((a, b) => a - b),
[chef, zwei, drei].sort((a, b) => a - b),
'wer dabei war, ist auch danach dabei',
);
assert.equal(neu.predecessor.inactiveReason, 'REWORKED');
});
});
describe('Einzelne Mitglieder fallen aus', () => {
async function paar(praefix: string) {
const chef = await spielerMitPaket(`${praefix}-A`);
const partner = await spielerMitPaket(`${praefix}-B`);
const { character } = await characters.createCharacter(chef, { name: `${praefix}-Duo` }, ctx);
await characters.addMember(character.id, partner, { useKind: 'CC' }, ctx);
return { chef, partner, character };
}
it('lässt einen einzelnen Nutzer sterben, während der CC weiterläuft', async () => {
const { chef, partner, character } = await paar('Tod');
await characters.deactivateMember(character.id, partner, 'DEAD', ctx, 'Von SCP-096 erwischt');
const gruppe = await db.prisma.character.findUniqueOrThrow({ where: { id: character.id } });
assert.equal(gruppe.status, 'ACTIVE', 'der CC selbst lebt weiter');
const uebrig = await db.prisma.characterMember.findMany({
where: { characterId: character.id, leftAt: null },
});
assert.deepEqual(uebrig.map((m) => m.playerId), [chef]);
const toter = await db.prisma.characterMember.findFirstOrThrow({
where: { characterId: character.id, playerId: partner },
});
assert.equal(toter.inactiveReason, 'DEAD');
assert.equal(toter.inactiveNote, 'Von SCP-096 erwischt');
});
it('zeigt dem Ausgefallenen seinen CC weiter an', async () => {
const { partner, character } = await paar('Sichtbar');
await characters.deactivateMember(character.id, partner, 'PLAYER_INACTIVITY', ctx);
const seine = await characters.listCharacters(partner);
assert.ok(
seine.some((c) => c.id === character.id),
'sonst könnte er nie zurückkehren',
);
});
it('verlangt für einen Tod eine Begründung', async () => {
const { partner, character } = await paar('Begruendung');
await assert.rejects(
() => characters.deactivateMember(character.id, partner, 'DEAD', ctx),
(error: Error & { code?: string }) => error.code === 'NEED_NOTE',
);
});
it('holt einen geputzten Nutzer gratis zurück', async () => {
const { partner, character } = await paar('Gratis');
await characters.deactivateMember(character.id, partner, 'PLAYER_INACTIVITY', ctx);
const vorher = await credits.getBalances(partner);
await characters.reactivateMember(character.id, partner, {}, ctx);
const dabei = await db.prisma.characterMember.findFirstOrThrow({
where: { characterId: character.id, playerId: partner },
});
assert.equal(dabei.leftAt, null);
assert.equal(dabei.inactiveReason, null);
assert.deepEqual(await credits.getBalances(partner), vorher, 'gratis heißt gratis');
});
it('lässt einen entzogenen Platz nur gegen Guthaben zurückkommen', async () => {
const { partner, character } = await paar('Entzogen');
await characters.deactivateMember(character.id, partner, 'NO_FREE_CLAIM', ctx, 'Regelverstoß');
await assert.rejects(
() => characters.reactivateMember(character.id, partner, {}, ctx),
(error: Error & { code?: string }) => error.code === 'NEED_KIND',
);
const vorher = await credits.getBalances(partner);
await characters.reactivateMember(character.id, partner, { useKind: 'REDESIGN_PAID' }, ctx);
assert.equal(
(await credits.getBalances(partner)).REDESIGN_PAID,
vorher.REDESIGN_PAID - 1,
'bezahlt',
);
});
it('verlangt für die Rückkehr eines Gestorbenen eine Begründung', async () => {
const { partner, character } = await paar('Rueckkehr');
await characters.deactivateMember(character.id, partner, 'DEAD', ctx, 'gestorben');
await assert.rejects(
() => characters.reactivateMember(character.id, partner, {}, ctx),
(error: Error & { code?: string }) => error.code === 'NEED_REASON',
);
await characters.reactivateMember(
character.id,
partner,
{ reason: 'Klon aus der Lagerhalle' },
ctx,
);
const dabei = await db.prisma.characterMember.findFirstOrThrow({
where: { characterId: character.id, playerId: partner },
});
assert.equal(dabei.leftAt, null);
});
it('macht aus einem freiwilligen Austritt keine Rückkehr auf Kosten des Hauses', async () => {
const { partner, character } = await paar('Freiwillig');
await characters.removeMember(character.id, partner, 'Keine Lust mehr', ctx);
await assert.rejects(
() => characters.reactivateMember(character.id, partner, {}, ctx),
(error: Error & { code?: string }) => error.code === 'WAS_VOLUNTARY',
'wer freiwillig geht, tritt neu bei und zahlt neu',
);
});
it('lässt den letzten Nutzer nicht einzeln ausfallen', async () => {
const allein = await spielerMitPaket('Letzter');
const { character } = await characters.createCharacter(allein, { name: 'Alleingang' }, ctx);
await assert.rejects(
() => characters.deactivateMember(character.id, allein, 'PLAYER_INACTIVITY', ctx),
(error: Error & { code?: string }) => error.code === 'LAST_MEMBER',
'dann ist der ganze CC betroffen',
);
});
});
+39
View File
@@ -3,10 +3,14 @@ import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Schedule = typeof import('../src/services/schedule.js');
type Scraper = typeof import('../src/services/scraper.js');
type Forum = typeof import('../src/services/forum.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let schedule: Schedule;
let scraper: Scraper;
let forum: Forum;
let db: Db;
const JETZT = new Date('2026-09-21T12:00:00Z');
@@ -22,6 +26,8 @@ before(async () => {
db = await import('../src/db.js');
schedule = await import('../src/services/schedule.js');
scraper = await import('../src/services/scraper.js');
forum = await import('../src/services/forum.js');
});
after(async () => {
@@ -166,3 +172,36 @@ describe('Der nächtliche Forumslauf', () => {
assert.equal(schedule.forumLaufFaellig(heuteFrueh, nachStart, nacht), false);
});
});
describe('Zwei Läufe zugleich', () => {
it('lässt einen zweiten GExtension-Abgleich nicht daneben laufen', async () => {
// Ein Ziel, das es nicht gibt: der Lauf beginnt, scheitert am Abruf und
// endet - aber solange er läuft, ist die Tür zu.
const ziele = [{ playerId: 1, steamId: '76561198000029999' }];
const ctx = { actorId: null, actorName: 'Test' };
const laufend = scraper.scrapePlayers(ziele, ctx);
assert.equal(scraper.getScrapeState().running, true);
await assert.rejects(
() => scraper.scrapePlayers(ziele, ctx),
(error: Error & { code?: string }) => error.code === 'SCRAPE_RUNNING',
'sonst riefen beide dieselben Profile ab',
);
assert.throws(
() => scraper.startScrape(ziele, ctx),
(error: Error & { code?: string }) => error.code === 'SCRAPE_RUNNING',
);
await laufend.catch(() => {});
assert.equal(scraper.getScrapeState().running, false, 'danach ist wieder offen');
});
it('gilt für den Zeitplan genauso wie für einen Klick', () => {
// Beide Wege gehen durch dieselbe Funktion - der Zeitplan ruft
// scanForum direkt, die Oberfläche startScan. Die Sperre sitzt deshalb
// unten, nicht im Starter.
assert.equal(typeof forum.scanForum, 'function');
assert.equal(forum.getScanState().running, false);
});
});