Files
CC-Manager/server/src/services/characters.ts
T
Harlekin 367f1aace4 Jedes Glied einer Liste kostet, auch in einem Gruppen-CC
Bisher zahlte nur, wer neu einstieg: wer in einer Kette mitgezogen
wurde oder einen Gruppen-CC mitbenutzte, stand darin ohne eigene
Buchung. Damit ging die Rechnung nicht auf - verbrauchte Redesigns sind
alle Glieder aller Listen minus die Anzahl der Listen, und das gilt fuer
jeden Nutzer einzeln.

Jetzt bucht jedes Glied: das unterste einen Platz, jedes weitere ein
Redesign. Wo eine Buchung fehlt, traegt der Abgleich sie nach, und das
Redesign eines Gruppen-CCs kostet jeden Nutzer sein eigenes.

Am Pfeil stehen alle offenen Buchungen eines Eintrags, nicht nur die
juengste - sonst bleibt eine zweite unsichtbar.
2026-09-22 13:01:52 +02:00

2604 lines
81 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 lineageId = randomUUID();
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,
createdById: ctx.actorId,
// Der Ersteller ist zugleich das erste Mitglied. Die Zugehörigkeit
// läuft durchgehend über diese Tabelle, auch bei Einzel-CCs - und
// daran hängt seine Liste: hier beginnt eine neue.
members: {
create: [
{ playerId, lineageId, chainPosition: 0, 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: JoinKind;
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");
}
// Der Regelfall ist ein Redesign-Guthaben. Wer ausnahmsweise sein
// CC-Guthaben einsetzt, wandelt es um - siehe bucheVerbrauch().
if (!JOIN_KINDS.includes(input.useKind)) {
throw badRequest("INVALID_KIND", "Ungültige Guthaben-Art für ein Redesign");
}
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,
lineageId: true,
chainPosition: 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,
// Für jeden Nutzer rückt der neue CC in seiner eigenen Liste über
// den alten - auch bei einem Gruppen-CC, der bei dem einen der
// gekaufte Platz ist und beim anderen ein Redesign.
members: {
create: bisherige.map((mitglied) => ({
playerId: mitglied.playerId,
paidWith: mitglied.paidWith,
lineageId: mitglied.lineageId,
chainPosition: mitglied.chainPosition + 10,
predecessorId: old.id,
createdById: ctx.actorId,
})),
},
},
});
await bucheVerbrauch(tx, {
playerId: old.playerId,
characterId: created.id,
kind: input.useKind,
redesign: true,
note: input.note ?? `Redesign: ${old.name} -> ${created.name}`,
actorId: ctx.actorId,
});
// Bei einem Gruppen-CC zählt der Eintrag für jeden Nutzer einzeln: in
// seiner Liste steht ab jetzt ein Glied mehr, und das kostet ihn ein
// Redesign wie jedes andere.
for (const mitglied of bisherige) {
if (mitglied.playerId === old.playerId) continue;
await bucheVerbrauch(tx, {
playerId: mitglied.playerId,
characterId: created.id,
kind: CreditKind.REDESIGN_PAID,
redesign: true,
note: `Redesign des Gruppen-CCs: ${old.name} -> ${created.name}`,
actorId: 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 bucheVerbrauch(tx, {
playerId: updated.playerId,
characterId: updated.id,
kind: useKind,
redesign: null,
note: note ?? `Reaktivierung: ${updated.name}`,
actorId: 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) {
const ccs = await 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 } },
},
},
},
});
// Womit dieser Spieler den jeweiligen Eintrag bezahlt hat. In der Kette
// steht das am Übergang: der Pfeil zum nächsten Glied trägt die Art, mit
// der dieses Glied bezahlt wurde.
const buchungen = await prisma.creditLedger.findMany({
where: {
characterId: { in: ccs.map((c) => c.id) },
playerId,
source: CreditSource.CONSUME,
reversedBy: null,
},
select: { characterId: true, kind: true },
orderBy: { id: "desc" },
});
// Alle offenen, nicht nur die jüngste: an einem Eintrag kann mehr als eine
// hängen - eine Rückkehr etwa kostet zusätzlich. Wer nur die letzte zeigt,
// versteckt die andere.
const artNach = new Map<number, CreditKind[]>();
for (const buchung of buchungen) {
if (buchung.characterId === null) continue;
const bisher = artNach.get(buchung.characterId) ?? [];
bisher.push(buchung.kind);
artNach.set(buchung.characterId, bisher);
}
// Die Liste gehört dem Spieler: Ein Gruppen-CC steht bei jedem Nutzer an
// dessen eigener Stelle. Gezeigt wird deshalb seine Sicht, nicht die des
// Erstellers.
return ccs
.map((cc) => {
const eigene = cc.members.find((m) => m.playerId === playerId);
return {
...cc,
lineageId: eigene?.lineageId ?? cc.lineageId,
chainPosition: eigene?.chainPosition ?? cc.chainPosition,
predecessorId: eigene?.predecessorId ?? null,
predecessor:
eigene?.predecessorId === cc.predecessorId ? cc.predecessor : null,
fundedWith: artNach.get(cc.id) ?? [],
};
})
.sort(
(a, b) =>
a.lineageId.localeCompare(b.lineageId) ||
a.chainPosition - b.chainPosition ||
a.createdAt.getTime() - b.createdAt.getTime(),
);
}
/**
* 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 bucheVerbrauch(tx, {
playerId: updated.playerId,
characterId: updated.id,
kind: input.useKind,
redesign: null,
note: `Rückkehr nach Tod: ${input.reason}`,
actorId: 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 um, woraus ein Eintrag bezahlt wurde.
*
* Zu entscheiden bleibt eines: ob ein Redesign aus dem bezahlten Topf kommt
* oder aus Kulanz. Ob ein Eintrag überhaupt ein Redesign ist, steht nicht
* zur Wahl - das entscheidet seine Stelle in der Liste.
*
* Geändert wird 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,
forPlayerId?: number,
): Promise<CharacterResult> {
const character = await loadCharacter(characterId);
const spieler = forPlayerId ?? character.playerId;
if (!JOIN_KINDS.includes(useKind)) {
throw badRequest("INVALID_KIND", "Ungültige Guthaben-Art");
}
// Die Buchung, mit der dieser Spieler seinen Platz bezahlt hat. Bei einem
// Gruppen-CC hat jeder Nutzer seine eigene; stornierte zählen nicht mehr.
const buchung = await prisma.creditLedger.findFirst({
where: {
characterId,
playerId: spieler,
source: CreditSource.CONSUME,
reversedBy: null,
},
orderBy: { id: "desc" },
});
if (!buchung) {
throw conflict(
"NO_BOOKING",
"Für diesen Spieler ist an diesem CC nichts gebucht - hier gibt es nichts umzustellen. " +
"Sein Platz hängt weiter unten in der Liste.",
);
}
const redesign = await istRedesignPosition(prisma, characterId, spieler);
if (!redesign && useKind !== CreditKind.CC) {
throw conflict(
"CHAIN_START",
"Dieser Eintrag steht am Anfang einer Liste und bucht einen eigenen Platz. " +
"Erst über einem anderen Eintrag wird daraus ein Redesign.",
);
}
if (redesign && useKind === CreditKind.CC) {
throw conflict(
"CONVERT_FIRST",
"Ein Redesign wird nicht aus dem CC-Topf bezahlt. Wandle das CC-Guthaben oben in ein " +
"Redesign um, dann steht es hier zur Verfügung.",
);
}
if (buchung.kind === useKind) {
throw conflict("SAME_KIND", "Dieser CC ist bereits so gebucht");
}
const grund =
useKind === CreditKind.REDESIGN_KULANZ
? "Umgestellt auf Kulanz"
: "Umgestellt auf bezahltes Redesign";
await prisma.$transaction(async (tx) => {
await storniere(tx, buchung, `${grund}: ${character.name}`, ctx.actorId);
await tx.creditLedger.create({
data: {
playerId: spieler,
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,
playerId: spieler,
from: buchung.kind,
to: useKind,
},
});
});
const balances = await getBalances(spieler);
return { character, balances, overdrawn: isOverdrawn(balances) };
}
// --- Ketten ---
/**
* Steht dieser Eintrag für diesen Spieler über einem Vorgänger?
*
* Für den Besitzer steht das am CC selbst, für ein Mitglied an seiner
* Mitgliedschaft: wer einem Gruppen-CC beitritt, gibt dafür einen eigenen CC
* auf, und welcher das war, steht dort. Daran hängt alles Weitere - am
* Anfang einer Kette wird ein Platz gekauft, darüber wird redesignt.
*/
async function istRedesignPosition(
db: Prisma.TransactionClient | typeof prisma,
characterId: number,
playerId: number,
): Promise<boolean> {
const character = await db.character.findUnique({
where: { id: characterId },
select: { playerId: true, predecessorId: true },
});
if (!character) return false;
if (character.playerId === playerId) return character.predecessorId !== null;
const mitglied = await db.characterMember.findUnique({
where: { characterId_playerId: { characterId, playerId } },
select: { predecessorId: true },
});
return (mitglied?.predecessorId ?? null) !== null;
}
/**
* Bucht, was ein Eintrag kostet.
*
* Am Anfang einer Liste steht ein gekaufter Platz, alles darüber ein
* Redesign. Die Stelle entscheidet, nicht die Wahl - sonst ginge die
* Rechnung "eine Liste, ein CC-Paket" nicht mehr auf. Wer sein CC-Guthaben
* für ein Redesign einsetzen will, wandelt es vorher um; danach ist es ein
* Redesign wie jedes andere.
*/
async function bucheVerbrauch(
tx: Prisma.TransactionClient,
params: {
playerId: number;
characterId: number;
kind: JoinKind;
/**
* Steht der Eintrag über einem Vorgänger? Dann ist es ein Redesign.
*
* `null` bei einer Buchung, die neben einer bestehenden steht - eine
* Rückkehr etwa kostet zusätzlich, und was sie kostet, entscheidet der
* Fall, nicht die Stelle in der Liste.
*/
redesign: boolean | null;
note: string;
actorId: number | null;
occurredAt?: Date | undefined;
},
): Promise<void> {
const ausCcTopf = params.kind === CreditKind.CC;
if (params.redesign === true && ausCcTopf) {
throw conflict(
"CONVERT_FIRST",
"Ein Redesign wird nicht aus dem CC-Topf bezahlt. Wandle das CC-Guthaben oben in ein " +
"Redesign um, dann steht es hier zur Verfügung.",
);
}
if (params.redesign === false && !ausCcTopf) {
throw conflict(
"CHAIN_START",
"Dieser Eintrag steht am Anfang einer Liste und bucht einen eigenen Platz. Dafür wird ein " +
"CC-Guthaben gebraucht, kein Redesign.",
);
}
await tx.creditLedger.create({
data: {
playerId: params.playerId,
kind: params.kind,
amount: -1,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId: params.characterId,
note: params.note,
createdById: params.actorId,
...(params.occurredAt ? { occurredAt: params.occurredAt } : {}),
},
});
}
/** Nimmt eine Buchung zurück: die Gegenbuchung trägt Art und Gruppe der alten. */
async function storniere(
tx: Prisma.TransactionClient,
buchung: {
id: number;
playerId: number;
kind: CreditKind;
amount: number;
groupId: string;
characterId: number | null;
occurredAt: Date;
},
note: string,
actorId: number | null,
): Promise<void> {
await tx.creditLedger.create({
data: {
playerId: buchung.playerId,
kind: buchung.kind,
amount: -buchung.amount,
source: CreditSource.REVERSAL,
groupId: buchung.groupId,
characterId: buchung.characterId,
reversalOfId: buchung.id,
note,
createdById: actorId,
occurredAt: buchung.occurredAt,
},
});
}
/**
* 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; createdAt?: Date },
/**
* Wessen Buchung angefasst wird: der Spieler, dessen Liste geordnet wird -
* nicht der Ersteller des einzelnen Gliedes. Bei einem Gruppen-CC hat
* jeder Nutzer seine eigene Buchung, und nur die geht ihn etwas an.
*/
kettenBesitzer: number,
sollCC: boolean,
actorId: number | null,
): Promise<boolean> {
const offene = await tx.creditLedger.findMany({
where: {
characterId: glied.id,
playerId: kettenBesitzer,
source: CreditSource.CONSUME,
reversedBy: null,
},
orderBy: { id: "desc" },
});
const offen = offene[0];
// Jedes Glied einer Liste kostet etwas: das unterste ein CC-Paket, jedes
// weitere ein Redesign. Fehlt die Buchung ganz, wird sie hier nachgetragen
// - sonst ginge die Rechnung "eine Liste, ein Paket" nicht auf.
if (!offen) {
await tx.creditLedger.create({
data: {
playerId: kettenBesitzer,
kind: sollCC ? CreditKind.CC : CreditKind.REDESIGN_PAID,
amount: -1,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId: glied.id,
note: sollCC
? `Anfang einer Liste: ${glied.name}`
: `Redesign in einer Liste: ${glied.name}`,
createdById: actorId,
...(glied.createdAt ? { occurredAt: glied.createdAt } : {}),
},
});
return true;
}
// Steht schon etwas da, das zur Stelle passt, bleibt alles, wie es ist.
// An einem Eintrag kann mehr als eine Buchung hängen - eine Rückkehr
// kostet zusätzlich, und das soll sie auch.
if (offene.some((eintrag) => (eintrag.kind === CreditKind.CC) === sollCC))
return false;
// Ein Eintrag, der in eine Kette rückt, braucht kein eigenes Paket mehr:
// das Guthaben kommt zurück, dafür geht ein Redesign weg. Umgekehrt
// genauso.
const grund = sollCC
? "Steht jetzt am Anfang der Kette: aus einem CC-Paket bezahlt"
: "Steht jetzt in einer Kette: als Redesign gebucht";
await storniere(tx, offen, `${grund} - Storno: ${glied.name}`, actorId);
await tx.creditLedger.create({
data: {
playerId: offen.playerId,
kind: sollCC ? CreditKind.CC : CreditKind.REDESIGN_PAID,
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;
}
/**
* Setzt eine Liste neu auf, nach der Reihenfolge der Positionen.
*
* Eine Liste gehört einem Spieler: sie beginnt mit dem CC, für den er ein
* Paket eingesetzt hat, und darüber stehen dessen Redesigns. Ein Gruppen-CC
* steht in der Liste jedes Nutzers an dessen eigener Stelle - für den einen
* kann er der gekaufte Platz sein, für den anderen ein Redesign.
*
* Die Liste 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,
playerId: number,
actorId: number | null,
): Promise<number> {
const glieder = await tx.characterMember.findMany({
where: { lineageId, playerId },
orderBy: [{ chainPosition: "asc" }, { joinedAt: "asc" }, { id: "asc" }],
select: {
id: true,
characterId: true,
chainPosition: true,
predecessorId: true,
leftAt: true,
inactiveReason: true,
character: {
select: {
id: true,
name: true,
playerId: true,
status: true,
inactiveReason: true,
createdAt: true,
},
},
},
});
const inDieserLinie = new Set(glieder.map((g) => g.characterId));
let umgebucht = 0;
let vorheriges: number | null = null;
for (const [index, glied] of glieder.entries()) {
const letztes = index === glieder.length - 1;
const erstes = index === 0;
const cc = glied.character;
const daten: Prisma.CharacterMemberUpdateInput = {};
if (erstes) {
// Am Anfang wird nur eine Verkettung gelöst, die innerhalb dieser
// Liste ins Leere zeigt. Eine Herkunft von aussen bleibt: sie ist das
// Einzige, was noch zeigt, woher die Liste 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, steht am Anfang einer Liste
// und hat dafür ein Paket eingesetzt; wer einen hat, ist dessen
// Redesign. Ob der Vorgänger in derselben Liste geführt wird, spielt
// keine Rolle - die Kette ist die Kette.
const vorgaengerDanach = erstes
? inDieserLinie.has(glied.predecessorId ?? -1)
? null
: glied.predecessorId
: vorheriges;
if (await buchungFolgtPosition(tx, cc, playerId, vorgaengerDanach === null, actorId)) {
umgebucht += 1;
}
// Was einen Nachfolger hat, wurde durch ihn ersetzt - anders ist eine
// Liste nicht zu lesen. Beim eigenen CC heisst das: er ist redesignt.
// Bei einem Gruppen-CC eines anderen endet nur die eigene Nutzung; die
// Gruppe läuft unberührt weiter.
const eigener = cc.playerId === playerId;
if (eigener) {
const ccDaten: Prisma.CharacterUpdateInput = {};
if (!letztes && cc.inactiveReason !== InactiveReason.REWORKED) {
ccDaten.status = CharacterStatus.INACTIVE;
ccDaten.inactiveReason = InactiveReason.REWORKED;
ccDaten.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 && cc.inactiveReason === InactiveReason.REWORKED) {
ccDaten.status = CharacterStatus.ACTIVE;
ccDaten.inactiveReason = null;
ccDaten.inactiveNote = null;
}
// Die Liste des Besitzers steht auch am CC: daran hängt, was von
// aussen auf die Kette zeigt.
ccDaten.lineageId = lineageId;
ccDaten.chainPosition = glied.chainPosition;
ccDaten.predecessor =
vorgaengerDanach === null
? { disconnect: true }
: { connect: { id: vorgaengerDanach } };
await tx.character.update({ where: { id: cc.id }, data: ccDaten });
} else {
if (!letztes && glied.leftAt === null) {
daten.leftAt = new Date();
daten.inactiveReason = InactiveReason.REWORKED;
daten.inactiveNote = null;
}
if (letztes && glied.inactiveReason === InactiveReason.REWORKED) {
daten.leftAt = null;
daten.inactiveReason = null;
daten.inactiveNote = null;
}
}
if (Object.keys(daten).length > 0) {
await tx.characterMember.update({ where: { id: glied.id }, data: daten });
}
vorheriges = glied.characterId;
}
return umgebucht;
}
/**
* Gleicht die Listen eines Spielers durch.
*
* Massgeblich ist die Reihenfolge: Das unterste Glied einer Liste ist der
* gekaufte Platz, alles darüber sind Redesigns. Daraus ergeben sich die
* Vorgänger-Verweise, die Buchungen und was als ersetzt gilt - dieselbe
* Rechnung wie beim Verschieben, nur über alle Listen auf einmal.
*/
async function gleicheAn(
tx: Prisma.TransactionClient,
playerId: number,
actorId: number | null,
): Promise<{ geprueft: number; umgebucht: number }> {
const listen = await tx.characterMember.findMany({
where: { playerId },
select: { lineageId: true },
distinct: ['lineageId'],
});
let umgebucht = 0;
for (const liste of listen) {
umgebucht += await verketteNeu(tx, liste.lineageId, playerId, actorId);
}
return { geprueft: listen.length, umgebucht };
}
/**
* 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 }> {
let ergebnis = { geprueft: 0, umgebucht: 0 };
await prisma.$transaction(async (tx) => {
ergebnis = await gleicheAn(tx, playerId, ctx.actorId);
if (ergebnis.umgebucht > 0) {
await recordAudit(tx, ctx, {
action: "character.funding_rebuilt",
targetType: "player",
targetId: playerId,
details: { geprueft: ergebnis.geprueft, umgebucht: ergebnis.umgebucht },
});
}
});
return { ...ergebnis, balances: await getBalances(playerId) };
}
/**
* Gleicht die Buchungen aller Spieler an die Ketten an.
*
* Läuft beim Start mit: Buchungen aus der Zeit vor dieser Regel oder aus
* einer Ausnahme, die es nicht mehr gibt, werden einmalig geradegezogen. Was
* bereits passt, bleibt unangetastet - deshalb hinterlässt der Lauf im
* Kontobuch nichts, sobald alles stimmt.
*/
export async function richteBuchungenAus(
actorId: number | null = null,
): Promise<number> {
const spieler = await prisma.player.findMany({ select: { id: true } });
let umgebucht = 0;
for (const { id } of spieler) {
await prisma.$transaction(async (tx) => {
const ergebnis = await gleicheAn(tx, id, actorId);
umgebucht += ergebnis.umgebucht;
});
}
return umgebucht;
}
/**
* Begleicht ein Minus im Redesign-Topf mit vorhandener Kulanz.
*
* Kommt vor, wenn nachträglich klar wird, dass ein Teil der Redesigns
* Kulanz war: Dann stehen dort Guthaben herum, während der bezahlte Topf im
* Minus hängt. Statt einzeln Übergang für Übergang umzustellen, wird hier so
* viel umgebucht, wie beide Seiten hergeben.
*
* Welche Übergänge es trifft, spielt inhaltlich keine Rolle - am Ende zählt,
* wie viel woraus bezahlt wurde. Genommen werden die ältesten zuerst, damit
* das Ergebnis wiederholbar ist und nicht vom Zufall abhängt.
*/
export async function settleWithKulanz(
playerId: number,
ctx: ActionContext,
): Promise<{ umgebucht: number; balances: Balances }> {
const stand = await getBalances(playerId);
const schulden = Math.max(0, -stand.REDESIGN_PAID);
const kulanz = Math.max(0, stand.REDESIGN_KULANZ);
const moeglich = Math.min(schulden, kulanz);
if (moeglich === 0) {
throw conflict(
"NOTHING_TO_SETTLE",
schulden === 0
? "Im bezahlten Redesign-Topf steht kein Minus - hier gibt es nichts auszugleichen"
: "Es ist keine Kulanz übrig, mit der sich das Minus ausgleichen liesse",
);
}
// Die ältesten bezahlten Redesigns zuerst: wer nachträglich Kulanz
// zugestanden bekommt, bekommt sie für das, was am längsten zurückliegt.
const kandidaten = await prisma.creditLedger.findMany({
where: {
playerId,
kind: CreditKind.REDESIGN_PAID,
amount: { lt: 0 },
source: CreditSource.CONSUME,
reversedBy: null,
},
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
take: moeglich,
include: { character: { select: { id: true, name: true } } },
});
if (kandidaten.length === 0) {
throw conflict(
"NOTHING_TO_SETTLE",
"Es gibt keine bezahlten Redesigns, die sich auf Kulanz umstellen liessen",
);
}
await prisma.$transaction(async (tx) => {
for (const buchung of kandidaten) {
await tx.creditLedger.create({
data: {
playerId,
kind: buchung.kind,
amount: -buchung.amount,
source: CreditSource.REVERSAL,
groupId: buchung.groupId,
characterId: buchung.characterId,
reversalOfId: buchung.id,
note: `Auf Kulanz umgestellt - Storno: ${buchung.character?.name ?? ""}`.trim(),
createdById: ctx.actorId,
occurredAt: buchung.occurredAt,
},
});
await tx.creditLedger.create({
data: {
playerId,
kind: CreditKind.REDESIGN_KULANZ,
amount: buchung.amount,
source: CreditSource.CONSUME,
groupId: randomUUID(),
characterId: buchung.characterId,
note: buchung.note ?? buchung.character?.name ?? null,
createdById: ctx.actorId,
occurredAt: buchung.occurredAt,
},
});
}
await recordAudit(tx, ctx, {
action: "credit.settled_with_kulanz",
targetType: "player",
targetId: playerId,
details: { umgebucht: kandidaten.length },
});
});
return {
umgebucht: kandidaten.length,
balances: await getBalances(playerId),
};
}
export async function moveInChain(
characterId: number,
input: {
lineageId: string;
afterId?: number | null;
/// Wessen Liste 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 spieler = input.forPlayerId ?? character.playerId;
/**
* Geordnet wird die eigene Liste.
*
* Nicht der Besitz entscheidet, sondern die Nutzung: Ein Gruppen-CC gehört
* dem, der ihn angelegt hat, steht aber in der Liste jedes Nutzers an
* dessen eigener Stelle. Wer ihn nutzt, ordnet damit seine eigene
* Geschichte - und die eines anderen rührt er nicht an.
*/
const mitglied = await prisma.characterMember.findUnique({
where: { characterId_playerId: { characterId, playerId: spieler } },
select: { id: true, lineageId: true },
});
if (!mitglied) {
throw conflict(
"FOREIGN_CHAIN",
"Dieser CC gehört zu einer anderen Geschichte - einordnen kann ihn nur, wer ihn nutzt",
);
}
const zielGlieder = await prisma.characterMember.findMany({
where: { lineageId: input.lineageId, playerId: spieler },
orderBy: [{ chainPosition: "asc" }, { id: "asc" }],
select: { id: true, characterId: true, chainPosition: true },
});
if (zielGlieder.length === 0) {
const fremde = await prisma.characterMember.count({
where: { lineageId: input.lineageId, playerId: { not: spieler } },
});
if (fremde > 0) {
throw conflict(
"FOREIGN_CHAIN",
"Diese Liste 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.characterId === 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 = mitglied.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.characterMember.updateMany({
where: {
lineageId: input.lineageId,
playerId: spieler,
chainPosition: { gte: ab },
id: { not: mitglied.id },
},
data: { chainPosition: { increment: 10 } },
});
await tx.characterMember.update({
where: { id: mitglied.id },
data: { lineageId: input.lineageId, chainPosition: ab },
});
await verketteNeu(tx, input.lineageId, spieler, ctx.actorId);
if (alteLinie !== input.lineageId)
await verketteNeu(tx, alteLinie, spieler, 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, spieler, "Dieses Verschieben");
await recordAudit(tx, ctx, {
action: "character.moved_in_chain",
targetType: "character",
targetId: characterId,
details: {
name: character.name,
forPlayerId: spieler,
fromLineage: alteLinie,
toLineage: input.lineageId,
afterId: input.afterId ?? null,
},
});
});
return { lineageId: input.lineageId };
}
/**
* Löst einen CC aus einer Liste 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,
forPlayerId?: number,
): Promise<{ lineageId: string }> {
const character = await loadCharacter(characterId);
const spieler = forPlayerId ?? character.playerId;
const neueLinie = randomUUID();
const mitglied = await prisma.characterMember.findUnique({
where: { characterId_playerId: { characterId, playerId: spieler } },
select: { id: true, lineageId: true },
});
if (!mitglied) {
throw conflict(
"FOREIGN_CHAIN",
"Dieser CC gehört zu einer anderen Geschichte - herauslösen kann ihn nur, wer ihn nutzt",
);
}
await prisma.$transaction(async (tx) => {
// Allein in einer neuen Liste 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.characterMember.update({
where: { id: mitglied.id },
data: { lineageId: neueLinie, chainPosition: 0, predecessorId: null },
});
// Beide Seiten: die verlassene Liste rückt zusammen, und der
// Herausgelöste steht allein - also am Anfang seiner eigenen.
await verketteNeu(tx, mitglied.lineageId, spieler, ctx.actorId);
await verketteNeu(tx, neueLinie, spieler, ctx.actorId);
await assertSlotsReichen(tx, spieler, "Dieses Herauslösen");
await recordAudit(tx, ctx, {
action: "character.detached",
targetType: "character",
targetId: characterId,
details: {
name: character.name,
forPlayerId: spieler,
fromLineage: mitglied.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,
/// Wer weiter unten in derselben Kette schon dabei war, setzt dort fort -
/// sein Vorgänger steht in seiner Liste, nicht in der Angabe.
schonInDerKette = false,
): 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) {
if (schonInDerKette) return null;
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> {
return (await fruehereNutzung(character, playerId)) !== null;
}
/**
* Der jüngste Eintrag, den der Spieler in der Kette dieses CCs schon nutzt.
*
* Daran hängt zweierlei: dass sein Platz längst bezahlt ist, und wo der CC
* in seiner eigenen Liste steht - nämlich darüber.
*/
async function fruehereNutzung(
character: Character,
playerId: number,
): Promise<{ lineageId: string; chainPosition: number } | null> {
return prisma.characterMember.findFirst({
where: {
playerId,
character: {
lineageId: character.lineageId,
chainPosition: { lt: character.chainPosition },
},
},
orderBy: { character: { chainPosition: "desc" } },
select: { lineageId: true, chainPosition: true },
});
}
/**
* Wo ein CC in der Liste eines Spielers landet.
*
* Wer einen eigenen CC dafür aufgibt, reiht sich darüber ein. Wer schon
* weiter unten in derselben Kette steckte, setzt dort fort. Der Ersteller
* bleibt, wo sein CC steht. Alle anderen beginnen eine eigene Liste - ein
* Platz, der für sich steht.
*/
async function platzInSeinerListe(
character: Character,
playerId: number,
vorgaengerId: number | null,
): Promise<{ lineageId: string; chainPosition: number }> {
if (vorgaengerId !== null) {
const vorher = await prisma.characterMember.findUnique({
where: { characterId_playerId: { characterId: vorgaengerId, playerId } },
select: { lineageId: true, chainPosition: true },
});
if (vorher) {
return {
lineageId: vorher.lineageId,
chainPosition: vorher.chainPosition + 10,
};
}
}
if (character.playerId === playerId) {
return {
lineageId: character.lineageId,
chainPosition: character.chainPosition,
};
}
const frueher = await fruehereNutzung(character, playerId);
if (frueher) {
return {
lineageId: frueher.lineageId,
chainPosition: frueher.chainPosition + 10,
};
}
return { lineageId: randomUUID(), chainPosition: 0 };
}
/**
* 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,
schonInDerKette,
);
const platz = await platzInSeinerListe(
character,
playerId,
vorgaenger?.id ?? null,
);
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,
lineageId: platz.lineageId,
chainPosition: platz.chainPosition,
note: input.note ?? null,
createdById: ctx.actorId,
},
});
} else {
await tx.characterMember.create({
data: {
characterId,
playerId,
paidWith: input.useKind ?? null,
predecessorId: vorgaenger?.id ?? null,
lineageId: platz.lineageId,
chainPosition: platz.chainPosition,
note: input.note ?? null,
createdById: ctx.actorId,
...(input.joinedAt ? { joinedAt: input.joinedAt } : {}),
...(input.leftAt ? { leftAt: input.leftAt } : {}),
},
});
}
// Wer einen eigenen CC aufgibt oder weiter unten in derselben Kette
// schon dabei war, steht hier über einem Vorgänger. Ohne Angabe ergibt
// sich die Art aus dieser Stelle - gebucht wird in jedem Fall, denn
// jedes Glied einer Liste kostet etwas.
const redesignPosition = vorgaenger !== null || schonInDerKette;
// Wer hier schon bezahlt hat, zahlt nicht noch einmal - etwa der
// Besitzer, der sich versehentlich ausgetragen hatte.
const schonBezahlt = await tx.creditLedger.findFirst({
where: {
characterId,
playerId,
source: CreditSource.CONSUME,
reversedBy: null,
},
select: { id: true },
});
if (schonBezahlt === null) {
await bucheVerbrauch(tx, {
playerId,
characterId,
kind:
input.useKind ??
(redesignPosition ? CreditKind.REDESIGN_PAID : CreditKind.CC),
redesign: redesignPosition,
note:
input.note ??
(vorgaenger
? `Beitritt zu "${character.name}" anstelle von "${vorgaenger.name}"`
: `Beitritt zu "${character.name}"`),
actorId: ctx.actorId,
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: JoinKind;
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 (!JOIN_KINDS.includes(input.useKind)) {
throw badRequest("INVALID_KIND", "Ungültige Guthaben-Art für ein Redesign");
}
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) => {
// In seiner Liste tritt der neue CC an die Stelle des Gruppen-CCs; für
// die übrigen Nutzer ändert sich nichts, der Gruppen-CC lebt weiter.
const erstellt = await tx.character.create({
data: {
playerId,
name,
loreText: input.loreText ?? null,
status: CharacterStatus.ACTIVE,
lineageId: mitglied.lineageId,
chainPosition: mitglied.chainPosition + 10,
predecessorId: gruppe.id,
createdById: ctx.actorId,
members: {
create: [
{
playerId,
paidWith: input.useKind,
lineageId: mitglied.lineageId,
chainPosition: mitglied.chainPosition + 10,
predecessorId: gruppe.id,
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 bucheVerbrauch(tx, {
playerId,
characterId: erstellt.id,
kind: input.useKind,
redesign: true,
note:
input.note ?? `Redesign aus "${gruppe.name}" heraus: ${erstellt.name}`,
actorId: 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 bucheVerbrauch(tx, {
playerId,
characterId,
kind: input.useKind,
redesign: null,
note: input.reason ?? `Rückkehr zu "${gruppe.name}"`,
actorId: 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;
// Jedes Glied zählt für ihn wie ein eigener CC: der Einstieg kostet,
// was er gewählt hat, jedes weitere Glied ein Redesign.
await addMember(
glied.id,
playerId,
{
...(index === 0
? {
...(input.useKind !== undefined
? { useKind: input.useKind }
: {}),
}
: { useKind: CreditKind.REDESIGN_PAID }),
...(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 } },
},
});
}