Ketten mit der Maus ordnen, Verweise an CCs, Umzug per Tabelle

Die Spielerseite zeigt CCs nicht mehr als zwei Listen "aktiv" und "inaktiv",
sondern als das, was sie sind: je Platz eine Kette, von unten nach oben
gelesen. Unten der erste CC, darueber seine Redesigns.

Gefunden wird selten alles auf einmal. Ein Redesign taucht Jahre spaeter auf,
weil sein Antrag in einem anderen Board lag oder weil der Spieler es fuer den
Beitritt zu einem fremden Gruppen-CC benutzt hat. Dann steht in der Kette
schon "3 auf 5", und 4 fehlt. Deshalb laesst sich jeder Eintrag mit der Maus
an seinen Platz ziehen - auch mitten in eine Kette und in jede andere Kette
desselben Spielers. Das Verschieben ordnet nur ein: es bucht nichts und
aendert keinen Status.

Dafuer traegt jeder CC jetzt eine Position in seiner Kette. Sie laesst sich
verschieben, ohne die ganze Kette neu zu haengen; der Vorgaenger-Verweis, an
dem der Rest der Anwendung haengt, wird danach aus den Positionen neu
abgeleitet. So koennen beide Sichten nicht auseinanderlaufen. Der Bestand
bekommt seine Positionen in der Migration aus der vorhandenen Verkettung.

An jedem CC haengen jetzt beliebig viele Verweise: ein Name und eine Adresse,
der Name wird zum Link. Nur http und https - ein "javascript:"-Link in einer
Oberflaeche, die mehrere Leute bedienen, waere ein offenes Scheunentor.

Die SteamID fuehrt auf der Spielerseite zum GExtension-Profil, der Forum-Name
zum Forumsprofil. Dessen Adresse kommt aus dem Abgleich: die Profile liegen
nicht unter dem Forums-Pfad, sondern eine Ebene darueber, und das laesst sich
nicht verlaesslich erraten - also wird uebernommen, worauf das Forum selbst
verweist.

Zuletzt der Umzug: unter Import gibt es fuer Administratoren den gesamten
Bestand als eine Tabelle - Spieler, Guthaben, CCs samt Ketten, frueherer
Konten, Mitgliedschaften und Verweise. Eingespielt wird ergaenzend: was da
ist, bleibt unangetastet, zweimal dieselbe Datei aendert nichts, und krumme
Zeilen werden mit Zeilennummer gemeldet statt den Lauf abzubrechen. Ein Test
spielt den vollen Weg durch - exportieren, Bestand loeschen, zurueckholen.
This commit is contained in:
2026-09-21 13:18:11 +02:00
parent 029d380abf
commit db37d3388a
22 changed files with 2472 additions and 357 deletions
+237 -1
View File
@@ -195,6 +195,9 @@ export async function redesignCharacter(
loreText: input.loreText ?? null,
status: CharacterStatus.ACTIVE,
lineageId: old.lineageId,
// Zehnerschritte, damit ein später gefundenes Zwischenglied noch
// dazwischen passt, ohne die ganze Kette anzufassen.
chainPosition: old.chainPosition + 10,
predecessorId: old.id,
createdById: ctx.actorId,
members: {
@@ -537,7 +540,7 @@ export async function listCharacters(playerId: number) {
some: { playerId, OR: [{ leftAt: null }, { inactiveReason: { not: null } }] },
},
},
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
orderBy: [{ lineageId: 'asc' }, { chainPosition: 'asc' }, { createdAt: 'asc' }],
include: {
predecessor: { select: { id: true, name: true } },
successors: { select: { id: true, name: true } },
@@ -546,6 +549,10 @@ export async function listCharacters(playerId: number) {
select: { threadId: true, title: true, url: true },
orderBy: { threadId: 'desc' },
},
links: {
select: { id: true, label: true, url: true },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
},
members: {
orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }],
include: {
@@ -633,6 +640,235 @@ export async function reviveDeadCharacter(
}
// --- Ketten ---
/**
* Setzt die Verkettung einer Linie neu auf, nach der Reihenfolge der
* Positionen.
*
* Die Kette steht doppelt in den Daten: als Position und als Verweis auf den
* Vorgänger. Die Position lässt sich verschieben, ohne alles anzufassen; der
* Vorgänger-Verweis ist das, woran der Rest der Anwendung hängt. Nach jedem
* Umsortieren wird er deshalb aus den Positionen neu abgeleitet - dann können
* beide nicht auseinanderlaufen.
*/
async function verketteNeu(tx: Prisma.TransactionClient, lineageId: string): Promise<void> {
const glieder = await tx.character.findMany({
where: { lineageId },
orderBy: [{ chainPosition: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
select: { id: true, predecessorId: true },
});
let vorheriges: number | null = null;
for (const glied of glieder) {
if (glied.predecessorId !== vorheriges) {
await tx.character.update({
where: { id: glied.id },
data: { predecessorId: vorheriges },
});
}
vorheriges = glied.id;
}
}
/**
* Verschiebt einen CC innerhalb seiner Kette oder in eine andere.
*
* Gedacht für das Nachtragen von Lücken: ein Redesign, das erst Jahre später
* auftaucht, gehört mitten in eine bestehende Kette - und nicht ans Ende,
* bloß weil es zuletzt eingetragen wurde. Am Status ändert das Verschieben
* nichts; wer aktiv war, bleibt aktiv.
*/
export async function moveInChain(
characterId: number,
input: { lineageId: string; afterId?: number | null },
ctx: ActionContext,
): Promise<{ lineageId: string }> {
const character = await loadCharacter(characterId);
// Die Ziel-Linie muss dem Spieler gehören, sonst wanderten CCs zwischen
// Spielern - dafür gibt es hier keinen Weg und keinen Grund.
const zielGlieder = await prisma.character.findMany({
where: { lineageId: input.lineageId },
orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }],
select: { id: true, playerId: true, chainPosition: true },
});
if (zielGlieder.length > 0 && zielGlieder.some((g) => g.playerId !== character.playerId)) {
throw conflict('FOREIGN_CHAIN', 'Diese Kette gehört einem anderen Spieler');
}
const vorgaenger =
input.afterId === undefined || input.afterId === null
? null
: zielGlieder.find((g) => g.id === input.afterId);
if (input.afterId != null && !vorgaenger) {
throw badRequest('NOT_IN_CHAIN', 'Der genannte Vorgänger liegt nicht in dieser Kette');
}
if (input.afterId === characterId) {
throw badRequest('SELF_REFERENCE', 'Ein CC kann nicht hinter sich selbst stehen');
}
const alteLinie = character.lineageId;
await prisma.$transaction(async (tx) => {
// Erst Platz schaffen: alles hinter der Einfügestelle rückt auf.
const ab = vorgaenger ? vorgaenger.chainPosition + 1 : 0;
await tx.character.updateMany({
where: {
lineageId: input.lineageId,
chainPosition: { gte: ab },
id: { not: characterId },
},
data: { chainPosition: { increment: 10 } },
});
await tx.character.update({
where: { id: characterId },
data: { lineageId: input.lineageId, chainPosition: ab },
});
await verketteNeu(tx, input.lineageId);
if (alteLinie !== input.lineageId) await verketteNeu(tx, alteLinie);
await recordAudit(tx, ctx, {
action: 'character.moved_in_chain',
targetType: 'character',
targetId: characterId,
details: {
name: character.name,
fromLineage: alteLinie,
toLineage: input.lineageId,
afterId: input.afterId ?? null,
},
});
});
return { lineageId: input.lineageId };
}
/**
* Löst einen CC aus seiner Kette und stellt ihn allein - für den Fall, dass
* sich herausstellt, dass er gar nicht dorthin gehörte.
*/
export async function detachFromChain(
characterId: number,
ctx: ActionContext,
): Promise<{ lineageId: string }> {
const character = await loadCharacter(characterId);
const neueLinie = randomUUID();
await prisma.$transaction(async (tx) => {
await tx.character.update({
where: { id: characterId },
data: { lineageId: neueLinie, chainPosition: 0, predecessorId: null },
});
await verketteNeu(tx, character.lineageId);
await recordAudit(tx, ctx, {
action: 'character.detached',
targetType: 'character',
targetId: characterId,
details: { name: character.name, fromLineage: character.lineageId },
});
});
return { lineageId: neueLinie };
}
// --- Verweise an einem CC ---
/// Nur diese Schemata. Ein "javascript:"-Link in einer Oberfläche, die
/// mehrere Leute bedienen, wäre ein offenes Scheunentor.
const ERLAUBTE_SCHEMATA = ['http:', 'https:'];
function pruefeUrl(roh: string): string {
const wert = roh.trim();
let parsed: URL;
try {
parsed = new URL(wert);
} catch {
throw badRequest('INVALID_URL', 'Das ist keine gültige Adresse - bitte mit http:// oder https://');
}
if (!ERLAUBTE_SCHEMATA.includes(parsed.protocol)) {
throw badRequest('INVALID_URL', 'Erlaubt sind nur Adressen mit http:// oder https://');
}
return parsed.toString();
}
/**
* Hängt einen Verweis an einen CC: das Steam-Addon des Erstellers, ein
* Modell, eine Absprache. Der Name wird in der Liste zum Link, damit dort
* steht, worum es geht, statt einer nackten Adresse.
*/
export async function addLink(
characterId: number,
input: { label: string; url: string },
ctx: ActionContext,
) {
const character = await loadCharacter(characterId);
const label = input.label.trim();
if (!label) throw badRequest('NO_LABEL', 'Für den Verweis wird ein Name gebraucht');
const created = await prisma.characterLink.create({
data: { characterId, label, url: pruefeUrl(input.url), createdById: ctx.actorId },
});
await recordAudit(prisma, ctx, {
action: 'character.link_added',
targetType: 'character',
targetId: characterId,
details: { name: character.name, label, url: created.url },
});
return created;
}
export async function updateLink(
linkId: number,
input: { label?: string | undefined; url?: string | undefined },
ctx: ActionContext,
) {
const vorhanden = await prisma.characterLink.findUnique({ where: { id: linkId } });
if (!vorhanden) throw notFound('Verweis');
const label = input.label?.trim();
if (input.label !== undefined && !label) {
throw badRequest('NO_LABEL', 'Für den Verweis wird ein Name gebraucht');
}
const updated = await prisma.characterLink.update({
where: { id: linkId },
data: {
...(label ? { label } : {}),
...(input.url !== undefined ? { url: pruefeUrl(input.url) } : {}),
},
});
await recordAudit(prisma, ctx, {
action: 'character.link_updated',
targetType: 'character',
targetId: vorhanden.characterId,
details: { label: updated.label, url: updated.url },
});
return updated;
}
export async function removeLink(linkId: number, ctx: ActionContext): Promise<void> {
const vorhanden = await prisma.characterLink.findUnique({ where: { id: linkId } });
if (!vorhanden) throw notFound('Verweis');
await prisma.characterLink.delete({ where: { id: linkId } });
await recordAudit(prisma, ctx, {
action: 'character.link_removed',
targetType: 'character',
targetId: vorhanden.characterId,
details: { label: vorhanden.label, url: vorhanden.url },
});
}
// --- Gruppen-CCs ---
/// Ein Platz in einem CC kostet ein CC-Guthaben oder ein Redesign - je nachdem,