Der Zugang aus der Erstinstallation ist ab Werk unantastbar: weder Rolle noch Zustand lassen sich aendern, sein Passwort nicht von aussen zuruecksetzen, und loeschen laesst er sich nicht - auch nicht durch sich selbst. So bleibt ein Weg zurueck, wenn sich jemand aus Versehen die Rechte entzieht. Sein eigenes Passwort aendert er weiterhin unter "Konto". BOOTSTRAP_ADMIN_EDITABLE=true hebt den Schutz auf. Welcher Zugang gemeint ist, traegt der Start nach: den Namen kennt nur er, die Migration nicht. Ein CC behielt nach dem Verschieben seines Redesigns den Vermerk "Redesignt", obwohl ueber ihm nichts mehr stand. Der Vermerk stammte von einem Nachfolger, der inzwischen woanders lag. Jetzt folgt er der Kette: wer einen Nachfolger hat, wurde ersetzt - wer ans Ende rueckt, nicht mehr. Ein Platz entsteht dabei nicht aus dem Nichts, die Deckung wird geprueft wie sonst auch. Neu bearbeitbar: Name, Beschreibung und der verknuepfte Forums-Antrag eines CCs. Ein Antrag, den der Abgleich noch nicht kennt, wird dabei vorgemerkt - sonst staende er beim naechsten Lauf als offener Antrag da, obwohl er laengst eingetragen ist. Ebenso laesst sich nachtraeglich umstellen, ob ein Redesign bezahlt oder Kulanz war; umgebucht wird ueber eine Gegenbuchung, ueberschrieben wird nichts. Zuletzt: Eintraege eines anderen Editors kamen nicht an, solange die Seite offen stand. Jetzt wird der Stand geholt, sobald der Tab wieder in den Vordergrund kommt - das trifft den Moment, in dem jemand wieder hinsieht, ohne im Hintergrund dauernd zu fragen.
1604 lines
53 KiB
TypeScript
1604 lines
53 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import {
|
|
CharacterStatus,
|
|
CreditKind,
|
|
CreditSource,
|
|
ForumClassification,
|
|
InactiveReason,
|
|
type Character,
|
|
type Prisma,
|
|
} from '@prisma/client';
|
|
import { prisma } from '../db.js';
|
|
import { badRequest, conflict, notFound } from '../errors.js';
|
|
import { getBalances, type Balances } from './credits.js';
|
|
import { recordAudit, type ActionContext } from './audit.js';
|
|
|
|
export interface CharacterResult {
|
|
character: Character;
|
|
balances: Balances;
|
|
// Zeigt an, dass ein Topf nach der Buchung im Minus steht. Die Aktion wird
|
|
// trotzdem ausgeführt, das Ergebnis gehört aber sichtbar gemacht.
|
|
overdrawn: boolean;
|
|
}
|
|
|
|
/// Diese Gründe lassen den CC unverändert bestehen - er kommt gratis zurück.
|
|
const FREE_REASONS = [InactiveReason.PLAYER_INACTIVITY, InactiveReason.OTHER] as const;
|
|
|
|
/// Hier ist der CC entweder ersetzt oder entzogen: die Rückkehr kostet ein Guthaben.
|
|
const PAID_REASONS = [InactiveReason.REWORKED, InactiveReason.NO_FREE_CLAIM] as const;
|
|
|
|
// Für ein Redesign zählen nur die beiden Redesign-Töpfe.
|
|
const REDESIGN_KINDS = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const;
|
|
// Beim Wiederbeleben eines reworkten CCs darf zusätzlich CC-Guthaben gezogen werden.
|
|
const REVIVE_KINDS = [CreditKind.CC, ...REDESIGN_KINDS] as const;
|
|
|
|
export type RedesignKind = (typeof REDESIGN_KINDS)[number];
|
|
export type ReviveKind = (typeof REVIVE_KINDS)[number];
|
|
|
|
function isOverdrawn(balances: Balances): boolean {
|
|
return balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0;
|
|
}
|
|
|
|
/**
|
|
* Die Slot-Regel.
|
|
*
|
|
* Ein CC-Paket ist ein Platz für einen gleichzeitig aktiven CC. Wer zwei
|
|
* Pakete hat, führt zwei CCs gleichzeitig - egal, wie viele Redesigns
|
|
* dazwischen liegen. Ein Redesign **tauscht** einen CC aus, es schafft keinen
|
|
* zusätzlichen Platz; fünf Millionen Redesigns auf ein Paket bleiben ein CC.
|
|
*
|
|
* Geprüft wird nicht der Kontostand, sondern die Deckung: **jeder aktive CC
|
|
* braucht eine eigene Buchung im CC-Topf.** Das ist der Unterschied zwischen
|
|
* einem bewusst überzogenen Konto - eine Buchung ist da, das Guthaben fehlt
|
|
* noch, und das Minus steht sichtbar auf der Spielerseite - und einem CC, der
|
|
* einfach zusätzlich aktiv wird, ohne dass irgendwo ein Platz dafür gebucht
|
|
* wurde. Das Erste ist gewollt, das Zweite wäre ein Platz aus dem Nichts.
|
|
*
|
|
* Geprüft wird an einer Stelle, statt an jedem Weg einzeln: erschaffen,
|
|
* redesignen, reaktivieren, zurückholen, einem Gruppen-CC beitreten.
|
|
*/
|
|
async function assertSlotsReichen(
|
|
tx: Prisma.TransactionClient,
|
|
playerId: number,
|
|
was: string,
|
|
): Promise<void> {
|
|
const [eintraege, belegt] = await Promise.all([
|
|
tx.creditLedger.findMany({
|
|
where: { playerId, kind: CreditKind.CC },
|
|
select: { amount: true, source: true, reversalOf: { select: { source: true } } },
|
|
}),
|
|
tx.characterMember.count({
|
|
where: { playerId, leftAt: null, character: { status: CharacterStatus.ACTIVE } },
|
|
}),
|
|
]);
|
|
|
|
// Verbräuche zählen, die nicht storniert wurden. Ein Storno trägt die Art
|
|
// der Zeile, die es zurücknimmt - nur so lässt sich ein zurückgenommener
|
|
// Platz von einem zurückgenommenen Paket unterscheiden.
|
|
let gebuchtePlaetze = 0;
|
|
for (const eintrag of eintraege) {
|
|
const zweck =
|
|
eintrag.source === CreditSource.REVERSAL ? eintrag.reversalOf?.source : eintrag.source;
|
|
if (zweck === CreditSource.CONSUME) gebuchtePlaetze -= eintrag.amount;
|
|
}
|
|
|
|
if (belegt > gebuchtePlaetze) {
|
|
throw conflict(
|
|
'NO_FREE_SLOT',
|
|
`${was} ist nicht möglich: Der Platz dieses CCs ist inzwischen von einem anderen belegt. ` +
|
|
'Ein CC-Paket ist ein Platz für einen gleichzeitig aktiven CC, und ein Redesign tauscht ' +
|
|
'einen CC aus, statt einen Platz hinzuzufügen. Entweder den anderen CC deaktivieren - ' +
|
|
'oder ein CC-Guthaben einsetzen, das einen eigenen Platz bucht.',
|
|
);
|
|
}
|
|
}
|
|
|
|
async function loadCharacter(id: number): Promise<Character> {
|
|
const character = await prisma.character.findUnique({ where: { id } });
|
|
if (!character) throw notFound('CC');
|
|
return character;
|
|
}
|
|
|
|
/** Neuer CC. Kostet ein CC-Guthaben, Kulanz ist dafür nicht einsetzbar. */
|
|
export async function createCharacter(
|
|
playerId: number,
|
|
input: { name: string; loreText?: string | undefined; note?: string | undefined },
|
|
ctx: ActionContext,
|
|
): Promise<CharacterResult> {
|
|
const player = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
|
|
if (!player) throw notFound('Spieler');
|
|
|
|
const character = await prisma.$transaction(async (tx) => {
|
|
const created = await tx.character.create({
|
|
data: {
|
|
playerId,
|
|
name: input.name,
|
|
loreText: input.loreText ?? null,
|
|
status: CharacterStatus.ACTIVE,
|
|
lineageId: randomUUID(),
|
|
createdById: ctx.actorId,
|
|
// Der Ersteller ist zugleich das erste Mitglied. Die Zugehörigkeit
|
|
// läuft durchgehend über diese Tabelle, auch bei Einzel-CCs.
|
|
members: { create: [{ playerId, createdById: ctx.actorId }] },
|
|
},
|
|
});
|
|
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId,
|
|
kind: CreditKind.CC,
|
|
amount: -1,
|
|
source: CreditSource.CONSUME,
|
|
groupId: randomUUID(),
|
|
characterId: created.id,
|
|
note: input.note ?? `Neuer CC: ${created.name}`,
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
|
|
await assertSlotsReichen(tx, playerId, 'Ein neuer CC');
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.create',
|
|
targetType: 'character',
|
|
targetId: created.id,
|
|
details: { playerId, name: created.name },
|
|
});
|
|
|
|
return created;
|
|
});
|
|
|
|
const balances = await getBalances(playerId);
|
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
|
}
|
|
|
|
/**
|
|
* Redesign als Lineage: der alte CC geht auf INACTIVE/REWORKED, der neue
|
|
* entsteht als eigener Datensatz in derselben Kette. Damit greift später
|
|
* automatisch die Regel, dass ein reworkter CC nur bezahlt zurückkommt.
|
|
*/
|
|
export async function redesignCharacter(
|
|
characterId: number,
|
|
input: {
|
|
name: string;
|
|
loreText?: string | undefined;
|
|
useKind: RedesignKind;
|
|
note?: string | undefined;
|
|
},
|
|
ctx: ActionContext,
|
|
): Promise<CharacterResult & { predecessor: Character }> {
|
|
const old = await loadCharacter(characterId);
|
|
|
|
if (old.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 result = await prisma.$transaction(async (tx) => {
|
|
const predecessor = await tx.character.update({
|
|
where: { id: old.id },
|
|
data: { status: CharacterStatus.INACTIVE, inactiveReason: InactiveReason.REWORKED },
|
|
});
|
|
|
|
// Ein Gruppen-CC bleibt nach dem Redesign eine Gruppe: alle, die zum
|
|
// Zeitpunkt des Redesigns dabei waren, sind es auch danach.
|
|
const bisherige = await tx.characterMember.findMany({
|
|
where: { characterId: old.id, leftAt: null },
|
|
select: { playerId: true, paidWith: true },
|
|
});
|
|
|
|
const created = await tx.character.create({
|
|
data: {
|
|
playerId: old.playerId,
|
|
name: input.name,
|
|
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: {
|
|
create: bisherige.map((mitglied) => ({
|
|
playerId: mitglied.playerId,
|
|
paidWith: mitglied.paidWith,
|
|
createdById: ctx.actorId,
|
|
})),
|
|
},
|
|
},
|
|
});
|
|
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId: old.playerId,
|
|
kind: input.useKind,
|
|
amount: -1,
|
|
source: CreditSource.CONSUME,
|
|
groupId: randomUUID(),
|
|
characterId: created.id,
|
|
note: input.note ?? `Redesign: ${old.name} -> ${created.name}`,
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
|
|
await assertSlotsReichen(tx, old.playerId, 'Dieses Redesign');
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.redesign',
|
|
targetType: 'character',
|
|
targetId: created.id,
|
|
details: {
|
|
playerId: old.playerId,
|
|
predecessorId: old.id,
|
|
from: old.name,
|
|
to: created.name,
|
|
useKind: input.useKind,
|
|
},
|
|
});
|
|
|
|
return { created, predecessor };
|
|
});
|
|
|
|
const balances = await getBalances(old.playerId);
|
|
return {
|
|
character: result.created,
|
|
predecessor: result.predecessor,
|
|
balances,
|
|
overdrawn: isOverdrawn(balances),
|
|
};
|
|
}
|
|
|
|
/** Deaktivierung ohne Guthaben-Effekt, etwa beim Serverputz wegen Inaktivität. */
|
|
export async function deactivateCharacter(
|
|
characterId: number,
|
|
reason: InactiveReason,
|
|
ctx: ActionContext,
|
|
note?: string,
|
|
): Promise<CharacterResult> {
|
|
const existing = await loadCharacter(characterId);
|
|
if (existing.status === CharacterStatus.INACTIVE) {
|
|
throw conflict('ALREADY_INACTIVE', 'Dieser CC ist bereits inaktiv');
|
|
}
|
|
if (reason === InactiveReason.REWORKED) {
|
|
throw badRequest(
|
|
'USE_REDESIGN',
|
|
'Ein Rework entsteht über die Redesign-Aktion, 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 character = await prisma.$transaction(async (tx) => {
|
|
const updated = await tx.character.update({
|
|
where: { id: characterId },
|
|
data: {
|
|
status: CharacterStatus.INACTIVE,
|
|
inactiveReason: reason,
|
|
inactiveNote: note?.trim() || null,
|
|
},
|
|
});
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.deactivate',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: { reason, note: note ?? null, name: updated.name },
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
|
|
const balances = await getBalances(character.playerId);
|
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
|
}
|
|
|
|
/**
|
|
* Gratis-Reaktivierung. Möglich, solange der CC unverändert weiterexistiert -
|
|
* ob er wegen Spieler-Inaktivität geputzt oder die Einheit zeitweise entfernt
|
|
* wurde, spielt keine Rolle. Ausgeschlossen sind ein Rework, ein Entzug ohne
|
|
* Anspruch und der Tod im Rollenspiel.
|
|
*/
|
|
export async function reactivateFree(
|
|
characterId: number,
|
|
ctx: ActionContext,
|
|
): Promise<CharacterResult> {
|
|
const existing = await loadCharacter(characterId);
|
|
|
|
if (existing.status === CharacterStatus.ACTIVE) {
|
|
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
|
|
}
|
|
if (existing.inactiveReason === InactiveReason.DEAD) {
|
|
throw conflict(
|
|
'IS_DEAD',
|
|
'Dieser CC ist im Rollenspiel gestorben. Seine Rückkehr entscheidet ein Administrator.',
|
|
);
|
|
}
|
|
if (!FREE_REASONS.includes(existing.inactiveReason as (typeof FREE_REASONS)[number])) {
|
|
throw conflict(
|
|
'NOT_FREE',
|
|
'Dieser CC hat keinen Anspruch auf kostenlose Rückkehr. Seine Reaktivierung kostet ein Guthaben.',
|
|
);
|
|
}
|
|
|
|
const character = await prisma.$transaction(async (tx) => {
|
|
const updated = await tx.character.update({
|
|
where: { id: characterId },
|
|
data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null },
|
|
});
|
|
|
|
await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs');
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.reactivate_free',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: { playerId: updated.playerId, name: updated.name },
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
|
|
const balances = await getBalances(character.playerId);
|
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
|
}
|
|
|
|
/**
|
|
* Bezahlte Reaktivierung, ausschließlich für redesignte CCs: deren Original
|
|
* existiert nicht mehr, es muss also neu erschaffen werden. Kostet ein
|
|
* Redesign- oder ein CC-Guthaben, der Nutzer entscheidet welches.
|
|
*/
|
|
export async function reactivatePaid(
|
|
characterId: number,
|
|
useKind: ReviveKind,
|
|
ctx: ActionContext,
|
|
note?: string,
|
|
): Promise<CharacterResult> {
|
|
const existing = await loadCharacter(characterId);
|
|
|
|
if (existing.status === CharacterStatus.ACTIVE) {
|
|
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
|
|
}
|
|
if (existing.inactiveReason === InactiveReason.DEAD) {
|
|
throw conflict(
|
|
'IS_DEAD',
|
|
'Ein im Rollenspiel gestorbener CC kehrt nur durch einen Administrator zurück.',
|
|
);
|
|
}
|
|
if (!PAID_REASONS.includes(existing.inactiveReason as (typeof PAID_REASONS)[number])) {
|
|
throw conflict(
|
|
'IS_FREE',
|
|
'Dieser CC existiert unverändert weiter und kommt gratis zurück. Eine Guthaben-Buchung wäre hier falsch.',
|
|
);
|
|
}
|
|
if (!REVIVE_KINDS.includes(useKind)) {
|
|
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für eine Reaktivierung');
|
|
}
|
|
|
|
const character = await prisma.$transaction(async (tx) => {
|
|
const updated = await tx.character.update({
|
|
where: { id: characterId },
|
|
data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null },
|
|
});
|
|
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId: updated.playerId,
|
|
kind: useKind,
|
|
amount: -1,
|
|
source: CreditSource.CONSUME,
|
|
groupId: randomUUID(),
|
|
characterId: updated.id,
|
|
note: note ?? `Reaktivierung: ${updated.name}`,
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
|
|
await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs');
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.reactivate_paid',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: {
|
|
playerId: updated.playerId,
|
|
name: updated.name,
|
|
useKind,
|
|
previousReason: existing.inactiveReason,
|
|
},
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
|
|
const balances = await getBalances(character.playerId);
|
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
|
}
|
|
|
|
/**
|
|
* Ändert die Angaben eines CCs: Name, Beschreibung und den Antrag, aus dem
|
|
* er hervorging.
|
|
*
|
|
* Der Antrag wird über seine Thread-Nummer oder die volle Adresse angegeben;
|
|
* ein leeres Feld löst die bestehende Verknüpfung. Kennt der Abgleich den
|
|
* Thread noch nicht, wird er vorgemerkt - sonst stünde er beim nächsten Lauf
|
|
* als offener Antrag da, obwohl er längst eingetragen ist.
|
|
*/
|
|
export async function updateCharacter(
|
|
characterId: number,
|
|
input: { name?: string; loreText?: string | null; threadRef?: string | null },
|
|
ctx: ActionContext,
|
|
): Promise<Character> {
|
|
const vorher = await loadCharacter(characterId);
|
|
|
|
const updated = await prisma.$transaction(async (tx) => {
|
|
const geaendert = await tx.character.update({
|
|
where: { id: characterId },
|
|
data: {
|
|
...(input.name !== undefined ? { name: input.name } : {}),
|
|
...(input.loreText !== undefined ? { loreText: input.loreText } : {}),
|
|
},
|
|
});
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.update',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: { name: geaendert.name, vorher: vorher.name },
|
|
});
|
|
|
|
return geaendert;
|
|
});
|
|
|
|
if (input.threadRef !== undefined) {
|
|
await setCharacterThread(characterId, input.threadRef, ctx);
|
|
}
|
|
|
|
return updated;
|
|
}
|
|
|
|
/**
|
|
* Hängt einen Forums-Antrag an einen CC oder löst ihn wieder.
|
|
*
|
|
* Angenommen wird die Thread-Nummer, der Pfad oder die volle Adresse - beim
|
|
* Eintippen von Hand landet mal das eine, mal das andere im Feld.
|
|
*/
|
|
export async function setCharacterThread(
|
|
characterId: number,
|
|
ref: string | null,
|
|
ctx: ActionContext,
|
|
): Promise<void> {
|
|
const character = await loadCharacter(characterId);
|
|
const { parseThreadId } = await import('../lib/forum.js');
|
|
const { linkThreadToCharacter } = await import('./forum.js');
|
|
|
|
const bisher = await prisma.forumThread.findFirst({
|
|
where: { linkedCharacterId: characterId },
|
|
select: { threadId: true },
|
|
});
|
|
|
|
const eingabe = ref?.trim() ?? '';
|
|
const threadId = eingabe.length === 0 ? null : parseThreadId(eingabe);
|
|
|
|
if (eingabe.length > 0 && threadId === null) {
|
|
throw badRequest(
|
|
'INVALID_THREAD',
|
|
'Das ist keine Thread-Nummer und keine Forums-Adresse',
|
|
);
|
|
}
|
|
if (bisher?.threadId === threadId) return;
|
|
|
|
// Die alte Verknüpfung fällt weg. Der Thread bleibt als entschieden
|
|
// vermerkt: er wurde ja bearbeitet, nur hängt er jetzt an keinem CC mehr.
|
|
if (bisher) {
|
|
await prisma.forumThread.update({
|
|
where: { threadId: bisher.threadId },
|
|
data: { linkedCharacterId: null },
|
|
});
|
|
}
|
|
|
|
if (threadId === null) {
|
|
await recordAudit(prisma, ctx, {
|
|
action: 'character.thread_unlinked',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: { name: character.name, threadId: bisher?.threadId ?? null },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const vorhanden = await prisma.forumThread.findUnique({ where: { threadId } });
|
|
if (vorhanden) {
|
|
await prisma.forumThread.update({
|
|
where: { threadId },
|
|
data: {
|
|
linkedCharacterId: characterId,
|
|
needsClassification: false,
|
|
classifiedById: ctx.actorId,
|
|
classifiedAt: vorhanden.classifiedAt ?? new Date(),
|
|
playerId: vorhanden.playerId ?? character.playerId,
|
|
},
|
|
});
|
|
|
|
await recordAudit(prisma, ctx, {
|
|
action: 'character.thread_linked',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: { name: character.name, threadId },
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Unbekannter Thread: über denselben Weg vormerken wie bei einem von Hand
|
|
// eingetragenen Antrag, damit er nicht als offen wieder auftaucht.
|
|
await linkThreadToCharacter(threadId, characterId, ForumClassification.NEW_CC, ctx);
|
|
}
|
|
|
|
/**
|
|
* Nimmt einen irrtümlich angelegten CC komplett zurück: Verbrauchsbuchungen
|
|
* werden gegengebucht und ein durch das Redesign deaktivierter Vorgänger
|
|
* kehrt in den aktiven Zustand zurück.
|
|
*/
|
|
export async function revertCharacter(
|
|
characterId: number,
|
|
reason: string,
|
|
ctx: ActionContext,
|
|
): Promise<{ balances: Balances; restoredPredecessorId: number | null }> {
|
|
const existing = await prisma.character.findUnique({
|
|
where: { id: characterId },
|
|
include: { successors: { select: { id: true } }, predecessor: true },
|
|
});
|
|
if (!existing) throw notFound('CC');
|
|
|
|
if (existing.successors.length > 0) {
|
|
throw conflict(
|
|
'HAS_SUCCESSORS',
|
|
'Dieser CC hat Nachfolger in der Redesign-Kette. Bitte zuerst den jüngsten CC zurücknehmen.',
|
|
);
|
|
}
|
|
|
|
const consumed = await prisma.creditLedger.findMany({
|
|
where: { characterId, source: CreditSource.CONSUME },
|
|
include: { reversedBy: { select: { id: true } } },
|
|
});
|
|
const open = consumed.filter((entry) => entry.reversedBy === null);
|
|
|
|
const predecessor = existing.predecessor;
|
|
const restorePredecessor =
|
|
predecessor !== null &&
|
|
predecessor.status === CharacterStatus.INACTIVE &&
|
|
predecessor.inactiveReason === InactiveReason.REWORKED;
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
for (const entry of open) {
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId: entry.playerId,
|
|
kind: entry.kind,
|
|
amount: -entry.amount,
|
|
source: CreditSource.REVERSAL,
|
|
groupId: entry.groupId,
|
|
note: `Rücknahme "${existing.name}": ${reason}`,
|
|
reversalOfId: entry.id,
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (restorePredecessor && predecessor) {
|
|
await tx.character.update({
|
|
where: { id: predecessor.id },
|
|
data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null },
|
|
});
|
|
}
|
|
|
|
await tx.character.delete({ where: { id: characterId } });
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.revert',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: {
|
|
playerId: existing.playerId,
|
|
name: existing.name,
|
|
reason,
|
|
reversedLedgerIds: open.map((e) => e.id),
|
|
restoredPredecessorId: restorePredecessor ? predecessor?.id ?? null : null,
|
|
},
|
|
});
|
|
});
|
|
|
|
return {
|
|
balances: await getBalances(existing.playerId),
|
|
restoredPredecessorId: restorePredecessor ? predecessor?.id ?? null : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 ist **jede** Mitgliedschaft, auch eine beendete. Wer aus einem
|
|
* Gruppen-CC herausredesignt hat oder ausgetreten ist, war trotzdem darin -
|
|
* und das gehört zu seiner Geschichte. Ob daraus ein Anspruch folgt, ist eine
|
|
* andere Frage und steht am Grund der Mitgliedschaft; hier geht es darum, was
|
|
* überhaupt zu sehen ist.
|
|
*/
|
|
export async function listCharacters(playerId: number) {
|
|
return prisma.character.findMany({
|
|
where: { members: { some: { playerId } } },
|
|
orderBy: [{ lineageId: 'asc' }, { chainPosition: 'asc' }, { createdAt: 'asc' }],
|
|
include: {
|
|
predecessor: { select: { id: true, name: true } },
|
|
successors: { select: { id: true, name: true } },
|
|
// Der verknüpfte Antrag macht den Namen zum Link ins Forum.
|
|
forumThreads: {
|
|
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: {
|
|
player: { select: { id: true, displayName: true } },
|
|
successor: { select: { id: true, name: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* Bringt einen im Rollenspiel gestorbenen CC zurück. Anders als die übrigen
|
|
* Wege ist das eine Ausnahmeentscheidung: sie bleibt Administratoren
|
|
* vorbehalten, verlangt eine Begründung und hält beides im Protokoll fest.
|
|
*
|
|
* Ob dabei ein Guthaben fließt, entscheidet der Administrator - ein Tod ist
|
|
* weder automatisch ein Rework noch ein Serverputz.
|
|
*/
|
|
export async function reviveDeadCharacter(
|
|
characterId: number,
|
|
input: { reason: string; useKind?: ReviveKind | undefined },
|
|
ctx: ActionContext,
|
|
): Promise<CharacterResult> {
|
|
const existing = await loadCharacter(characterId);
|
|
|
|
if (existing.status === CharacterStatus.ACTIVE) {
|
|
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
|
|
}
|
|
if (existing.inactiveReason !== InactiveReason.DEAD) {
|
|
throw conflict(
|
|
'NOT_DEAD',
|
|
'Dieser CC ist nicht als im Rollenspiel gestorben vermerkt - bitte den normalen Weg nutzen',
|
|
);
|
|
}
|
|
if (!input.reason.trim()) {
|
|
throw badRequest('NEED_REASON', 'Für die Rückkehr eines toten CCs wird eine Begründung gebraucht');
|
|
}
|
|
if (input.useKind !== undefined && !REVIVE_KINDS.includes(input.useKind)) {
|
|
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art');
|
|
}
|
|
|
|
const character = await prisma.$transaction(async (tx) => {
|
|
const updated = await tx.character.update({
|
|
where: { id: characterId },
|
|
data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null },
|
|
});
|
|
|
|
if (input.useKind !== undefined) {
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId: updated.playerId,
|
|
kind: input.useKind,
|
|
amount: -1,
|
|
source: CreditSource.CONSUME,
|
|
groupId: randomUUID(),
|
|
characterId: updated.id,
|
|
note: `Rückkehr nach Tod: ${input.reason}`,
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
}
|
|
|
|
await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs');
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.revive_dead',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: {
|
|
playerId: updated.playerId,
|
|
name: updated.name,
|
|
reason: input.reason,
|
|
useKind: input.useKind ?? null,
|
|
previousNote: existing.inactiveNote,
|
|
},
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
|
|
const balances = await getBalances(character.playerId);
|
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
|
}
|
|
|
|
|
|
/**
|
|
* Stellt nachträglich um, woraus ein Redesign bezahlt wurde.
|
|
*
|
|
* Ob ein Redesign aus einem gekauften Paket oder aus Kulanz kam, stellt sich
|
|
* manchmal erst später heraus - etwa wenn eine Absprache auftaucht, von der
|
|
* beim Eintragen niemand wusste. Umgebucht wird nicht durch Überschreiben,
|
|
* sondern wie jede Korrektur im Kontobuch: die alte Zeile wird
|
|
* gegengebucht, die neue kommt dazu. So bleibt sichtbar, dass hier etwas
|
|
* geändert wurde, und wann.
|
|
*/
|
|
export async function changeRedesignKind(
|
|
characterId: number,
|
|
useKind: RedesignKind,
|
|
ctx: ActionContext,
|
|
): Promise<CharacterResult> {
|
|
const character = await loadCharacter(characterId);
|
|
|
|
if (!REDESIGN_KINDS.includes(useKind)) {
|
|
throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben');
|
|
}
|
|
|
|
// Die Buchung, mit der dieser CC bezahlt wurde. Stornierte zählen nicht
|
|
// mehr; offen ist immer höchstens eine.
|
|
const buchung = await prisma.creditLedger.findFirst({
|
|
where: {
|
|
characterId,
|
|
source: CreditSource.CONSUME,
|
|
kind: { in: [...REDESIGN_KINDS] },
|
|
reversedBy: null,
|
|
},
|
|
orderBy: { id: 'desc' },
|
|
});
|
|
if (!buchung) {
|
|
throw conflict(
|
|
'NO_REDESIGN_BOOKING',
|
|
'Für diesen CC ist kein offenes Redesign gebucht - hier gibt es nichts umzustellen',
|
|
);
|
|
}
|
|
if (buchung.kind === useKind) {
|
|
throw conflict('SAME_KIND', 'Dieses Redesign ist bereits so gebucht');
|
|
}
|
|
|
|
const grund =
|
|
useKind === CreditKind.REDESIGN_KULANZ
|
|
? 'Umgestellt auf Kulanz'
|
|
: 'Umgestellt auf bezahltes Redesign';
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId: buchung.playerId,
|
|
kind: buchung.kind,
|
|
amount: -buchung.amount,
|
|
source: CreditSource.REVERSAL,
|
|
groupId: buchung.groupId,
|
|
characterId,
|
|
reversalOfId: buchung.id,
|
|
note: `${grund}: ${character.name}`,
|
|
createdById: ctx.actorId,
|
|
occurredAt: buchung.occurredAt,
|
|
},
|
|
});
|
|
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId: buchung.playerId,
|
|
kind: useKind,
|
|
amount: buchung.amount,
|
|
source: CreditSource.CONSUME,
|
|
groupId: randomUUID(),
|
|
characterId,
|
|
note: buchung.note ?? `Redesign: ${character.name}`,
|
|
createdById: ctx.actorId,
|
|
// Bezahlt wurde damals, nicht heute.
|
|
occurredAt: buchung.occurredAt,
|
|
},
|
|
});
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.redesign_kind_changed',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: { name: character.name, from: buchung.kind, to: useKind },
|
|
});
|
|
});
|
|
|
|
const balances = await getBalances(character.playerId);
|
|
return { character, balances, overdrawn: isOverdrawn(balances) };
|
|
}
|
|
|
|
// --- 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, status: true, inactiveReason: true },
|
|
});
|
|
|
|
let vorheriges: number | null = null;
|
|
for (const [index, glied] of glieder.entries()) {
|
|
const letztes = index === glieder.length - 1;
|
|
const daten: Prisma.CharacterUpdateInput = {};
|
|
|
|
if (glied.predecessorId !== vorheriges) {
|
|
daten.predecessor = vorheriges === null ? { disconnect: true } : { connect: { id: vorheriges } };
|
|
}
|
|
|
|
// Was einen Nachfolger hat, wurde durch ihn ersetzt - anders ist eine
|
|
// Kette nicht zu lesen. Ein Glied mittendrin kann also nicht aktiv sein
|
|
// und auch nicht "geputzt": es ist redesignt, Punkt.
|
|
if (!letztes && glied.inactiveReason !== InactiveReason.REWORKED) {
|
|
daten.status = CharacterStatus.INACTIVE;
|
|
daten.inactiveReason = InactiveReason.REWORKED;
|
|
daten.inactiveNote = null;
|
|
}
|
|
|
|
// Umgekehrt: Wer ans Ende rückt, ist von niemandem mehr ersetzt worden.
|
|
// Die Markierung wäre dann schlicht falsch - sie stammt von einem
|
|
// Nachfolger, der inzwischen woanders steht.
|
|
if (letztes && glied.inactiveReason === InactiveReason.REWORKED) {
|
|
daten.status = CharacterStatus.ACTIVE;
|
|
daten.inactiveReason = null;
|
|
daten.inactiveNote = null;
|
|
}
|
|
|
|
if (Object.keys(daten).length > 0) {
|
|
await tx.character.update({ where: { id: glied.id }, data: daten });
|
|
}
|
|
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);
|
|
|
|
// Das Neuordnen kann einen CC ans Ende rücken und damit wieder aktiv
|
|
// machen. Ein Platz entsteht dadurch nicht: er muss gedeckt sein wie
|
|
// jeder andere.
|
|
await assertSlotsReichen(tx, character.playerId, 'Dieses Verschieben');
|
|
|
|
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) => {
|
|
// Allein in einer neuen Linie ist er zugleich Anfang und Ende. Eine
|
|
// Redesign-Markierung stammte von einem Nachfolger, der jetzt woanders
|
|
// steht - sie wäre nur noch falsch.
|
|
await tx.character.update({
|
|
where: { id: characterId },
|
|
data: {
|
|
lineageId: neueLinie,
|
|
chainPosition: 0,
|
|
predecessorId: null,
|
|
...(character.inactiveReason === InactiveReason.REWORKED
|
|
? { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null }
|
|
: {}),
|
|
},
|
|
});
|
|
|
|
await verketteNeu(tx, character.lineageId);
|
|
await assertSlotsReichen(tx, character.playerId, 'Dieses Herauslösen');
|
|
|
|
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,
|
|
/// worüber der Spieler hinzukommt.
|
|
const JOIN_KINDS = [CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const;
|
|
export type JoinKind = (typeof JOIN_KINDS)[number];
|
|
|
|
/// Beitritte, die auf einem Redesign beruhen und deshalb einen CC kosten.
|
|
const JOIN_REDESIGN_KINDS: JoinKind[] = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ];
|
|
|
|
/**
|
|
* Prüft den CC, den ein Spieler für seinen Platz aufgibt.
|
|
*
|
|
* Über ein Redesign gibt es keinen zusätzlichen CC, sondern einen anderen:
|
|
* ohne einen eigenen CC als Grundlage gibt es nichts zu ersetzen. Aktiv muss
|
|
* dieser nicht sein - auch ein längst inaktiver CC lässt sich redesignen.
|
|
* Ein CC-Guthaben dagegen kauft einen Platz und verlangt keinen Vorgänger.
|
|
*/
|
|
async function ladeAufgegebenenCC(
|
|
input: { useKind: JoinKind; predecessorId?: number | undefined },
|
|
playerId: number,
|
|
characterId: number,
|
|
): Promise<Character | null> {
|
|
if (!JOIN_REDESIGN_KINDS.includes(input.useKind)) {
|
|
if (input.predecessorId !== undefined) {
|
|
throw badRequest(
|
|
'NO_PREDECESSOR_NEEDED',
|
|
'Ein Platz aus dem CC-Guthaben ersetzt keinen bestehenden CC',
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (input.predecessorId === undefined) {
|
|
throw badRequest(
|
|
'NEED_PREDECESSOR',
|
|
'Ein Beitritt über ein Redesign ersetzt einen eigenen CC - bitte angeben, welchen',
|
|
);
|
|
}
|
|
if (input.predecessorId === characterId) {
|
|
throw conflict('SAME_CHARACTER', 'Ein CC kann nicht für sich selbst aufgegeben werden');
|
|
}
|
|
|
|
const vorgaenger = await prisma.character.findUnique({
|
|
where: { id: input.predecessorId },
|
|
include: { members: { where: { playerId, leftAt: null }, select: { id: true } } },
|
|
});
|
|
if (!vorgaenger) throw notFound('Aufgegebener CC');
|
|
|
|
// Aufgeben kann nur, wem der CC gehört. Bei einem aktiven CC zählt die
|
|
// Mitgliedschaft, bei einem inaktiven der Eintrag als Inhaber - dort ist
|
|
// die Mitgliedschaft beim Ausscheiden beendet worden.
|
|
const gehoertIhm =
|
|
vorgaenger.members.length > 0 ||
|
|
(vorgaenger.status !== CharacterStatus.ACTIVE && vorgaenger.playerId === playerId);
|
|
if (!gehoertIhm) {
|
|
throw conflict('NOT_OWN_CHARACTER', 'Dieser CC gehört dem Spieler nicht');
|
|
}
|
|
|
|
return vorgaenger;
|
|
}
|
|
|
|
/**
|
|
* Nimmt einen weiteren Spieler in einen CC auf. Für ihn gilt dasselbe wie für
|
|
* einen eigenen CC: der Platz kostet ein Guthaben. Ob über ein CC-Paket oder
|
|
* ein Redesign, entscheidet der Nutzer - beide Wege kommen vor.
|
|
*/
|
|
export async function addMember(
|
|
characterId: number,
|
|
playerId: number,
|
|
input: { useKind: JoinKind; predecessorId?: number | undefined; note?: string | undefined },
|
|
ctx: ActionContext,
|
|
): Promise<CharacterResult & { memberCount: number }> {
|
|
const character = await loadCharacter(characterId);
|
|
|
|
if (character.status !== CharacterStatus.ACTIVE) {
|
|
throw conflict('NOT_ACTIVE', 'Einem inaktiven CC kann niemand beitreten');
|
|
}
|
|
if (!JOIN_KINDS.includes(input.useKind)) {
|
|
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für einen Beitritt');
|
|
}
|
|
|
|
const player = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
|
|
if (!player) throw notFound('Spieler');
|
|
|
|
const vorhanden = await prisma.characterMember.findUnique({
|
|
where: { characterId_playerId: { characterId, playerId } },
|
|
});
|
|
if (vorhanden && vorhanden.leftAt === null) {
|
|
throw conflict('ALREADY_MEMBER', 'Dieser Spieler nutzt den CC bereits');
|
|
}
|
|
|
|
// Ein Redesign ersetzt einen CC, es kommt nie einer dazu. Wer über ein
|
|
// Redesign beitritt, gibt dafür einen eigenen CC auf - genau wie bei jedem
|
|
// anderen Redesign. Nur ein CC-Guthaben kauft einen zusätzlichen Platz.
|
|
const vorgaenger = await ladeAufgegebenenCC(input, playerId, characterId);
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
if (vorgaenger !== null && vorgaenger.status === CharacterStatus.ACTIVE) {
|
|
await tx.character.update({
|
|
where: { id: vorgaenger.id },
|
|
data: { status: CharacterStatus.INACTIVE, inactiveReason: InactiveReason.REWORKED },
|
|
});
|
|
// Der Spieler nutzt den aufgegebenen CC nicht mehr; bei einem Gruppen-CC
|
|
// laufen die übrigen Mitglieder dort unberührt weiter.
|
|
await tx.characterMember.updateMany({
|
|
where: { characterId: vorgaenger.id, playerId, leftAt: null },
|
|
data: { leftAt: new Date() },
|
|
});
|
|
}
|
|
|
|
if (vorhanden) {
|
|
// Wiedereintritt: der alte Eintrag wird fortgeschrieben, damit die
|
|
// Zugehörigkeit nicht doppelt in der Liste steht.
|
|
await tx.characterMember.update({
|
|
where: { id: vorhanden.id },
|
|
data: {
|
|
leftAt: null,
|
|
joinedAt: new Date(),
|
|
paidWith: input.useKind,
|
|
predecessorId: vorgaenger?.id ?? null,
|
|
note: input.note ?? null,
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
} else {
|
|
await tx.characterMember.create({
|
|
data: {
|
|
characterId,
|
|
playerId,
|
|
paidWith: input.useKind,
|
|
predecessorId: vorgaenger?.id ?? null,
|
|
note: input.note ?? null,
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
}
|
|
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId,
|
|
kind: input.useKind,
|
|
amount: -1,
|
|
source: CreditSource.CONSUME,
|
|
groupId: randomUUID(),
|
|
characterId,
|
|
note:
|
|
input.note ??
|
|
(vorgaenger
|
|
? `Beitritt zu "${character.name}" anstelle von "${vorgaenger.name}"`
|
|
: `Beitritt zu "${character.name}"`),
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
|
|
await assertSlotsReichen(tx, playerId, 'Der Beitritt');
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.member_added',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: {
|
|
playerId,
|
|
name: character.name,
|
|
useKind: input.useKind,
|
|
predecessorId: vorgaenger?.id ?? null,
|
|
predecessorName: vorgaenger?.name ?? null,
|
|
},
|
|
});
|
|
});
|
|
|
|
const [balances, memberCount] = await Promise.all([
|
|
getBalances(playerId),
|
|
prisma.characterMember.count({ where: { characterId, leftAt: null } }),
|
|
]);
|
|
|
|
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
|
|
* Rücknahme der Buchung der richtige Weg, nicht der Austritt.
|
|
*/
|
|
export async function removeMember(
|
|
characterId: number,
|
|
playerId: number,
|
|
note: string | undefined,
|
|
ctx: ActionContext,
|
|
): Promise<{ memberCount: number }> {
|
|
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. Statt ihn auszutragen, den CC deaktivieren.',
|
|
);
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.characterMember.update({
|
|
where: { id: mitglied.id },
|
|
data: { leftAt: new Date(), note: note ?? mitglied.note },
|
|
});
|
|
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.member_removed',
|
|
targetType: 'character',
|
|
targetId: characterId,
|
|
details: { playerId, name: mitglied.character.name, note: note ?? null },
|
|
});
|
|
});
|
|
|
|
return { memberCount: verbleibend - 1 };
|
|
}
|
|
|
|
/** Alle, die einen CC nutzen oder genutzt haben. */
|
|
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 } },
|
|
successor: { select: { id: true, name: true } },
|
|
predecessor: { select: { id: true, name: true } },
|
|
},
|
|
});
|
|
}
|