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
+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',