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.
2604 lines
81 KiB
TypeScript
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 } },
|
|
},
|
|
});
|
|
}
|