Ein CC landete unter "Frueher genutzt", obwohl er dem Spieler gehoert - seine Mitgliedschaft daran war beendet, und danach allein richtete sich die Einordnung. Damit stand sein eigener CC in einem Abschnitt, der ausdruecklich fuer fremde gedacht ist, und liess sich aus der Ferne kaum in eine Kette ziehen. Jetzt entscheidet bei einem eigenen CC der Besitz: Wem er gehoert, dem gehoert er, auch wenn die Mitgliedschaft beendet wurde. Es bleibt seine Geschichte. Bei fremden CCs entscheidet weiterhin die Mitgliedschaft. Zwei Dinge fielen dabei auf: - Beim Ziehen scrollt der Browser nicht von selbst. Wer einen Eintrag von ganz unten in eine Kette weiter oben bringen wollte, sah das Ziel gar nicht. Jetzt scrollt die Seite, solange am Rand gezogen wird. - Der Besitzer konnte sich an seinem eigenen CC nicht ohne neue Buchung wieder eintragen, obwohl seine Buchung seit dem Anlegen daran haengt. Fuer ihn ist die Guthaben-Angabe jetzt freigestellt; von einem Fremden wird sie weiterhin verlangt.
1948 lines
64 KiB
TypeScript
1948 lines
64 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 CC bezahlt wurde.
|
|
*
|
|
* Ob etwas aus einem gekauften Paket, einem bezahlten Redesign oder aus
|
|
* Kulanz kam, stellt sich manchmal erst später heraus - wenn eine Absprache
|
|
* auftaucht, von der beim Eintragen niemand wusste, oder wenn sich zeigt,
|
|
* dass ein Eintrag gar nicht der Anfang einer Kette war.
|
|
*
|
|
* 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 changeFunding(
|
|
characterId: number,
|
|
useKind: JoinKind,
|
|
ctx: ActionContext,
|
|
): Promise<CharacterResult> {
|
|
const character = await loadCharacter(characterId);
|
|
|
|
if (!JOIN_KINDS.includes(useKind)) {
|
|
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art');
|
|
}
|
|
|
|
// 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,
|
|
playerId: character.playerId,
|
|
source: CreditSource.CONSUME,
|
|
reversedBy: null,
|
|
},
|
|
orderBy: { id: 'desc' },
|
|
});
|
|
if (!buchung) {
|
|
throw conflict(
|
|
'NO_BOOKING',
|
|
'Für diesen CC ist nichts gebucht - hier gibt es nichts umzustellen',
|
|
);
|
|
}
|
|
if (buchung.kind === useKind) {
|
|
throw conflict('SAME_KIND', 'Dieser CC ist bereits so gebucht');
|
|
}
|
|
|
|
const grund =
|
|
useKind === CreditKind.CC
|
|
? 'Umgestellt auf CC-Paket'
|
|
: 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.funding_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.
|
|
*/
|
|
/**
|
|
* Bucht ein Kettenglied auf die Art um, die seine Stelle in der Kette
|
|
* verlangt.
|
|
*
|
|
* Die Regel steckt in der Kette selbst: **Das unterste Glied ist der CC, der
|
|
* gekauft wurde - alles darüber sind seine Redesigns.** Wandert ein Eintrag,
|
|
* wandert damit auch seine Bedeutung: Ein eigenständiger CC, der mitten in
|
|
* eine fremde Kette geschoben wird, ist von da an ein Redesign; wer
|
|
* umgekehrt an den Anfang rückt oder allein steht, belegt einen eigenen
|
|
* Platz und kostet ein CC-Paket.
|
|
*
|
|
* Umgebucht wird wie jede Korrektur im Kontobuch: die alte Zeile wird
|
|
* gegengebucht, die neue kommt dazu. Wo gar keine Buchung steht, bleibt es
|
|
* dabei - wer ohne Buchung nachgetragen hat, wollte das so.
|
|
*
|
|
* Zwischen bezahltem Redesign und Kulanz wird nicht gewechselt: das ist eine
|
|
* Entscheidung, keine Folge der Reihenfolge. Sie steht unter "Bezahlart".
|
|
*/
|
|
async function buchungFolgtPosition(
|
|
tx: Prisma.TransactionClient,
|
|
glied: { id: number; name: string },
|
|
/**
|
|
* Wessen Buchung angefasst wird: der Spieler, dem die Kette gehört - nicht
|
|
* der Ersteller des einzelnen Gliedes. Bei einem fremden Gruppen-CC in
|
|
* seiner Kette ist das seine Beitrittsbuchung, und nur die geht ihn etwas
|
|
* an. Die des Erstellers bleibt unberührt; für den ist es sein eigener
|
|
* Platz in seiner eigenen Kette.
|
|
*/
|
|
kettenBesitzer: number,
|
|
sollCC: boolean,
|
|
actorId: number | null,
|
|
): Promise<boolean> {
|
|
const offen = await tx.creditLedger.findFirst({
|
|
where: {
|
|
characterId: glied.id,
|
|
playerId: kettenBesitzer,
|
|
source: CreditSource.CONSUME,
|
|
reversedBy: null,
|
|
},
|
|
orderBy: { id: 'desc' },
|
|
});
|
|
if (!offen) return false;
|
|
|
|
const istCC = offen.kind === CreditKind.CC;
|
|
if (istCC === sollCC) return false;
|
|
|
|
const neueArt = sollCC ? CreditKind.CC : CreditKind.REDESIGN_PAID;
|
|
const grund = sollCC
|
|
? `Steht jetzt am Anfang der Kette: aus einem CC-Paket bezahlt`
|
|
: `Steht jetzt in einer Kette: als Redesign gebucht`;
|
|
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId: offen.playerId,
|
|
kind: offen.kind,
|
|
amount: -offen.amount,
|
|
source: CreditSource.REVERSAL,
|
|
groupId: offen.groupId,
|
|
characterId: glied.id,
|
|
reversalOfId: offen.id,
|
|
note: `${grund} - Storno: ${glied.name}`,
|
|
createdById: actorId,
|
|
occurredAt: offen.occurredAt,
|
|
},
|
|
});
|
|
|
|
await tx.creditLedger.create({
|
|
data: {
|
|
playerId: offen.playerId,
|
|
kind: neueArt,
|
|
amount: offen.amount,
|
|
source: CreditSource.CONSUME,
|
|
groupId: randomUUID(),
|
|
characterId: glied.id,
|
|
note: offen.note ?? glied.name,
|
|
createdById: actorId,
|
|
// Bezahlt wurde damals, nicht beim Umsortieren.
|
|
occurredAt: offen.occurredAt,
|
|
},
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
async function verketteNeu(
|
|
tx: Prisma.TransactionClient,
|
|
lineageId: string,
|
|
actorId: number | null,
|
|
): Promise<void> {
|
|
const glieder = await tx.character.findMany({
|
|
where: { lineageId },
|
|
orderBy: [{ chainPosition: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }],
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
playerId: true,
|
|
predecessorId: true,
|
|
status: true,
|
|
inactiveReason: true,
|
|
},
|
|
});
|
|
|
|
// Die Kette gehört dem, der unten steht: bei ihm fing sie an.
|
|
const kettenBesitzer = glieder[0]?.playerId ?? null;
|
|
const inDieserLinie = new Set(glieder.map((g) => g.id));
|
|
|
|
let vorheriges: number | null = null;
|
|
for (const [index, glied] of glieder.entries()) {
|
|
const letztes = index === glieder.length - 1;
|
|
const erstes = index === 0;
|
|
const daten: Prisma.CharacterUpdateInput = {};
|
|
|
|
if (erstes) {
|
|
// Am Anfang wird nur eine Verkettung gelöst, die innerhalb dieser
|
|
// Linie ins Leere zeigt. Eine Herkunft von aussen bleibt: sie ist das
|
|
// Einzige, was noch zeigt, woher die Kette kommt.
|
|
if (glied.predecessorId !== null && inDieserLinie.has(glied.predecessorId)) {
|
|
daten.predecessor = { disconnect: true };
|
|
}
|
|
} else if (glied.predecessorId !== vorheriges) {
|
|
daten.predecessor = { connect: { id: vorheriges as number } };
|
|
}
|
|
|
|
// Wer nach dem Ordnen keinen Vorgänger hat, ist der Anfang einer Kette
|
|
// und damit ein gekaufter CC; wer einen hat, ist dessen Redesign. Ob
|
|
// der Vorgänger in derselben Linie geführt wird, spielt keine Rolle -
|
|
// die Kette ist die Kette.
|
|
const vorgaengerDanach = erstes
|
|
? inDieserLinie.has(glied.predecessorId ?? -1)
|
|
? null
|
|
: glied.predecessorId
|
|
: vorheriges;
|
|
|
|
if (kettenBesitzer !== null) {
|
|
await buchungFolgtPosition(tx, glied, kettenBesitzer, vorgaengerDanach === null, actorId);
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rechnet alle Buchungen eines Spielers anhand der Ketten nach.
|
|
*
|
|
* Die Regel ist schlicht: **Jede Kette beginnt mit einem gekauften CC, alles
|
|
* Weitere darin sind Redesigns.** Bei drei Ketten mit zusammen sechzehn
|
|
* Einträgen sind das drei CC-Pakete und dreizehn Redesigns.
|
|
*
|
|
* Gebraucht wird das, wenn ein Bestand aus einer Zeit stammt, in der eine
|
|
* Buchung noch blieb, wo sie beim Anlegen entstand - oder wenn beim
|
|
* Nacherfassen viel umsortiert wurde. Angefasst wird nur, was nicht passt,
|
|
* und umgebucht wird wie sonst auch: über eine Gegenbuchung. Zwischen
|
|
* bezahltem Redesign und Kulanz wird nichts gewechselt.
|
|
*/
|
|
export async function rebuildFunding(
|
|
playerId: number,
|
|
ctx: ActionContext,
|
|
): Promise<{ geprueft: number; umgebucht: number; balances: Balances }> {
|
|
const mitgliedschaften = await prisma.characterMember.findMany({
|
|
where: { playerId },
|
|
select: { character: { select: { id: true, name: true, predecessorId: true } } },
|
|
});
|
|
|
|
let umgebucht = 0;
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
for (const { character } of mitgliedschaften) {
|
|
// Kein Vorgänger heisst: hier fing eine Kette an, also ein gekaufter
|
|
// CC. Sonst ist es das Redesign seines Vorgängers.
|
|
const geaendert = await buchungFolgtPosition(
|
|
tx,
|
|
character,
|
|
playerId,
|
|
character.predecessorId === null,
|
|
ctx.actorId,
|
|
);
|
|
if (geaendert) umgebucht += 1;
|
|
}
|
|
|
|
if (umgebucht > 0) {
|
|
await recordAudit(tx, ctx, {
|
|
action: 'character.funding_rebuilt',
|
|
targetType: 'player',
|
|
targetId: playerId,
|
|
details: { geprueft: mitgliedschaften.length, umgebucht },
|
|
});
|
|
}
|
|
});
|
|
|
|
return {
|
|
geprueft: mitgliedschaften.length,
|
|
umgebucht,
|
|
balances: await getBalances(playerId),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
/// Wessen Geschichte hier geordnet wird. Bei einem Gruppen-CC ist das
|
|
/// nicht zwingend der Ersteller.
|
|
forPlayerId?: number | undefined;
|
|
},
|
|
ctx: ActionContext,
|
|
): Promise<{ lineageId: string }> {
|
|
const character = await loadCharacter(characterId);
|
|
|
|
const zielGlieder = await prisma.character.findMany({
|
|
where: { lineageId: input.lineageId },
|
|
orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }],
|
|
select: { id: true, playerId: true, chainPosition: true },
|
|
});
|
|
|
|
/**
|
|
* Wer hier ordnen darf.
|
|
*
|
|
* Nicht der Besitz entscheidet, sondern die Beteiligung: Ein Gruppen-CC
|
|
* gehört dem, der ihn angelegt hat, aber seine Geschichte gehört allen,
|
|
* die darin steckten. Wer seinen eigenen CC aufgegeben hat, um
|
|
* dazuzukommen, ordnet damit seine eigene Kette - und muss den Gruppen-CC
|
|
* dort einreihen können.
|
|
*
|
|
* Verlangt wird deshalb, dass derselbe Spieler an beidem beteiligt ist:
|
|
* am verschobenen CC und an der Kette, in die er soll. Sonst würden fremde
|
|
* Geschichten vermischt.
|
|
*/
|
|
const beteiligt = async (playerId: number, ids: number[]): Promise<boolean> => {
|
|
if (ids.length === 0) return true;
|
|
const treffer = await prisma.characterMember.findFirst({
|
|
where: { playerId, characterId: { in: ids } },
|
|
select: { id: true },
|
|
});
|
|
return treffer !== null;
|
|
};
|
|
|
|
const kandidat = input.forPlayerId ?? character.playerId;
|
|
const darfOrdnen =
|
|
(await beteiligt(kandidat, [characterId])) &&
|
|
(await beteiligt(kandidat, zielGlieder.map((g) => g.id)));
|
|
|
|
if (!darfOrdnen) {
|
|
throw conflict(
|
|
'FOREIGN_CHAIN',
|
|
'Diese Kette gehört zu einer anderen Geschichte - verschieben kann nur, wer an beidem beteiligt war',
|
|
);
|
|
}
|
|
|
|
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, ctx.actorId);
|
|
if (alteLinie !== input.lineageId) await verketteNeu(tx, alteLinie, ctx.actorId);
|
|
|
|
// 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 }
|
|
: {}),
|
|
},
|
|
});
|
|
|
|
// Beide Seiten: die verlassene Kette rückt zusammen, und der
|
|
// Herausgelöste steht allein - also am Anfang seiner eigenen Kette.
|
|
await verketteNeu(tx, character.lineageId, ctx.actorId);
|
|
await verketteNeu(tx, neueLinie, ctx.actorId);
|
|
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 | undefined; predecessorId?: number | undefined },
|
|
playerId: number,
|
|
characterId: number,
|
|
): Promise<Character | null> {
|
|
if (input.useKind === undefined || !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;
|
|
}
|
|
|
|
/**
|
|
* Ob der Spieler in einem früheren Glied derselben Kette geführt wird.
|
|
*
|
|
* Für die Nacherfassung entscheidend: Wer bei einem CC dabei war, der später
|
|
* redesignt wurde, ist beim Nachfolger dabei, ohne ein zweites Mal zu zahlen.
|
|
* Sein Platz wurde einmal gebucht und ist derselbe geblieben.
|
|
*/
|
|
async function warSchonInDerKette(character: Character, playerId: number): Promise<boolean> {
|
|
const treffer = await prisma.characterMember.findFirst({
|
|
where: {
|
|
playerId,
|
|
character: {
|
|
lineageId: character.lineageId,
|
|
chainPosition: { lt: character.chainPosition },
|
|
},
|
|
},
|
|
select: { id: true },
|
|
});
|
|
return treffer !== null;
|
|
}
|
|
|
|
/**
|
|
* 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: {
|
|
/// Fehlt bei einer reinen Nacherfassung an einem inaktiven CC: dort wird
|
|
/// eingetragen, was war, und die Buchung steht längst im Konto.
|
|
useKind?: JoinKind | undefined;
|
|
predecessorId?: number | undefined;
|
|
note?: string | undefined;
|
|
/// Zeitraum, wenn nachgetragen wird statt jetzt beizutreten.
|
|
joinedAt?: Date | undefined;
|
|
leftAt?: Date | undefined;
|
|
},
|
|
ctx: ActionContext,
|
|
): Promise<CharacterResult & { memberCount: number }> {
|
|
const character = await loadCharacter(characterId);
|
|
const historisch = character.status !== CharacterStatus.ACTIVE;
|
|
|
|
// Wer weiter unten in derselben Kette schon dabei war, setzt hier nur
|
|
// fort: sein Platz wurde damals bezahlt, und ein Redesign tauscht einen
|
|
// Platz aus, statt einen zweiten zu verlangen. Genau das tut ein Redesign
|
|
// im laufenden Betrieb auch - es nimmt die Gruppe mit.
|
|
const schonInDerKette = await warSchonInDerKette(character, playerId);
|
|
|
|
// Ebenso der Besitzer an seinem eigenen CC: Seine Buchung hängt daran,
|
|
// seit er ihn angelegt hat. Wer sich dort wieder einträgt - etwa weil er
|
|
// sich versehentlich selbst ausgetragen hat -, zahlt nicht ein zweites
|
|
// Mal für denselben Platz.
|
|
const istBesitzer = character.playerId === playerId;
|
|
|
|
// Sonst gilt: an einem laufenden CC kostet ein Platz etwas. An einem
|
|
// inaktiven wird nachgetragen, was einmal war - dort ist die Angabe
|
|
// freigestellt, weil die Buchung längst im Konto stehen kann.
|
|
if (!historisch && !schonInDerKette && !istBesitzer && input.useKind === undefined) {
|
|
throw badRequest(
|
|
'NEED_KIND',
|
|
'Ein Platz an einem laufenden CC kostet ein Guthaben - bitte angeben, welches',
|
|
);
|
|
}
|
|
if (input.useKind !== undefined && !JOIN_KINDS.includes(input.useKind)) {
|
|
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für einen Beitritt');
|
|
}
|
|
if (input.leftAt && input.joinedAt && input.leftAt < input.joinedAt) {
|
|
throw badRequest('BAD_RANGE', 'Das Ende liegt vor dem Anfang');
|
|
}
|
|
|
|
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: input.leftAt ?? null,
|
|
joinedAt: input.joinedAt ?? new Date(),
|
|
paidWith: input.useKind ?? null,
|
|
predecessorId: vorgaenger?.id ?? null,
|
|
note: input.note ?? null,
|
|
createdById: ctx.actorId,
|
|
},
|
|
});
|
|
} else {
|
|
await tx.characterMember.create({
|
|
data: {
|
|
characterId,
|
|
playerId,
|
|
paidWith: input.useKind ?? null,
|
|
predecessorId: vorgaenger?.id ?? null,
|
|
note: input.note ?? null,
|
|
createdById: ctx.actorId,
|
|
...(input.joinedAt ? { joinedAt: input.joinedAt } : {}),
|
|
...(input.leftAt ? { leftAt: input.leftAt } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
if (input.useKind !== undefined) {
|
|
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,
|
|
...(input.joinedAt ? { occurredAt: input.joinedAt } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
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 ?? null,
|
|
nachgetragen: historisch,
|
|
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 ab einem Kettenglied in die gesamte weitere Kette ein.
|
|
*
|
|
* Für die Nacherfassung: Stellt sich heraus, dass jemand schon beim dritten
|
|
* von sechs Gliedern dabei war, wäre es Unfug, ihn viermal einzeln
|
|
* einzutragen. Gebucht wird genau einmal - beim Einstieg -, denn ein
|
|
* Redesign tauscht seinen Platz aus, statt einen weiteren zu verlangen.
|
|
*
|
|
* Wer schon irgendwo in der Kette steht, wird dort nicht angerührt.
|
|
*/
|
|
export async function addMemberToChain(
|
|
characterId: number,
|
|
playerId: number,
|
|
input: {
|
|
useKind?: JoinKind | undefined;
|
|
predecessorId?: number | undefined;
|
|
note?: string | undefined;
|
|
joinedAt?: Date | undefined;
|
|
},
|
|
ctx: ActionContext,
|
|
): Promise<{ added: number; memberCount: number }> {
|
|
const einstieg = await loadCharacter(characterId);
|
|
|
|
const kette = await prisma.character.findMany({
|
|
where: { lineageId: einstieg.lineageId, chainPosition: { gte: einstieg.chainPosition } },
|
|
orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }],
|
|
select: { id: true, name: true },
|
|
});
|
|
|
|
const vorhandene = new Set(
|
|
(
|
|
await prisma.characterMember.findMany({
|
|
where: { playerId, characterId: { in: kette.map((c) => c.id) } },
|
|
select: { characterId: true },
|
|
})
|
|
).map((m) => m.characterId),
|
|
);
|
|
|
|
let added = 0;
|
|
for (const [index, glied] of kette.entries()) {
|
|
if (vorhandene.has(glied.id)) continue;
|
|
|
|
// Nur der Einstieg kostet. Alles Weitere ist dieselbe Mitgliedschaft,
|
|
// durch die Kette fortgeschrieben.
|
|
await addMember(
|
|
glied.id,
|
|
playerId,
|
|
{
|
|
...(index === 0 ? { useKind: input.useKind } : {}),
|
|
...(index === 0 && input.predecessorId !== undefined
|
|
? { predecessorId: input.predecessorId }
|
|
: {}),
|
|
...(input.note !== undefined ? { note: input.note } : {}),
|
|
...(input.joinedAt !== undefined ? { joinedAt: input.joinedAt } : {}),
|
|
},
|
|
ctx,
|
|
);
|
|
added += 1;
|
|
}
|
|
|
|
const letztes = kette[kette.length - 1];
|
|
const memberCount = letztes
|
|
? await prisma.characterMember.count({ where: { characterId: letztes.id, leftAt: null } })
|
|
: 0;
|
|
|
|
return { added, 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 } },
|
|
},
|
|
});
|
|
}
|