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