Tote CCs, Entzug und Gruppen-CCs
Bisher entschied allein das Redesign darueber, ob eine Rueckkehr etwas kostet. Es fehlten zwei Faelle: ein Entzug ohne Anspruch auf kostenlose Rueckkehr, und der Tod im Rollenspiel. Ein toter CC laesst sich weder gratis noch gegen Guthaben auf dem normalen Weg zurueckholen - das entscheidet ein Administrator ueber einen eigenen Weg, mit Pflicht-Begruendung, und waehlt dabei, ob ein Guthaben fliesst. Schon das Eintragen des Todes verlangt eine Notiz. Dazu koennen sich mehrere Spieler einen CC teilen. Die Zugehoerigkeit laeuft ab jetzt durchgehend ueber eine eigene Tabelle, auch bei Einzel-CCs; die Migration traegt bestehende CCs als erstes Mitglied ihres Erstellers nach. Ohne das waere nach dem Update kein CC mehr beim Spieler aufgetaucht. Fuer jeden Mitnutzer gelten dieselben Regeln wie fuer einen eigenen CC: der Platz kostet ein eigenes Guthaben, wahlweise aus dem CC-Paket oder ueber ein Redesign. Ein Austritt gibt nichts zurueck, denn bis dahin wurde der CC genutzt; fuer eine Fehleingabe ist das Storno der richtige Weg. Der letzte Nutzer laesst sich nicht austragen - dann wird der CC deaktiviert. Ein Redesign nimmt die Gruppe mit.
This commit is contained in:
@@ -18,10 +18,10 @@ Zwei Regeln sind fest verdrahtet:
|
||||
|
||||
1. **Ein CC-Paket erzeugt beide Guthaben.** Der inklusive Redesign wird automatisch mitgebucht,
|
||||
in derselben Transaktion und mit gemeinsamer Gruppen-ID - ein Storno nimmt beide zurück.
|
||||
2. **Nur redesignte CCs kosten bei der Rückkehr.** Solange ein CC unverändert existiert, kommt er
|
||||
2. **Der Grund entscheidet über die Rückkehr.** Solange ein CC unverändert existiert, kommt er
|
||||
gratis zurück - gleich ob der Spieler inaktiv war oder die Einheit zeitweise entfernt wurde.
|
||||
Wurde er dagegen redesignt, existiert das Original nicht mehr und die Reaktivierung kostet ein
|
||||
Redesign- oder CC-Guthaben.
|
||||
Wurde er redesignt oder entzogen, kostet die Rückkehr ein Redesign- oder CC-Guthaben. Ist er
|
||||
im Rollenspiel gestorben, entscheidet darüber ausschließlich ein Administrator, mit Begründung.
|
||||
|
||||
Guthaben werden als **Kontobuch** geführt: jede Bewegung ist eine eigene Zeile, der Stand eines
|
||||
Topfes ist deren Summe. Das macht jeden Stand nachvollziehbar und Korrekturen sauber - storniert
|
||||
@@ -33,7 +33,32 @@ Kette „A → B → C" sichtbar und die Reaktivierungsregel greift automatisch
|
||||
A zurück, ist A ein redesignter CC und kostet entsprechend.
|
||||
|
||||
Warum ein CC inaktiv wurde, lässt sich beim Deaktivieren als Klartext festhalten („Einheit bis
|
||||
März entfernt") - der Grund steht dann an der Karte, statt nur als Kategorie.
|
||||
März entfernt") - der Grund steht dann an der Karte, statt nur als Kategorie. Vier Gründe stehen
|
||||
zur Wahl:
|
||||
|
||||
| Grund | Rückkehr |
|
||||
|---|---|
|
||||
| Spieler-Inaktivität | gratis |
|
||||
| Anderer Grund (z. B. Einheit zeitweise entfernt) | gratis |
|
||||
| Entzogen | kostet ein Guthaben |
|
||||
| Im Rollenspiel gestorben | nur durch einen Administrator, mit Begründung |
|
||||
|
||||
## Gruppen-CCs
|
||||
|
||||
Manche CCs teilen sich mehrere Spieler. Für jeden gelten dabei dieselben Regeln wie für einen
|
||||
eigenen CC: **der Platz kostet ein eigenes Guthaben** - entweder aus dem CC-Paket oder über ein
|
||||
Redesign, je nachdem, worüber der Spieler hinzukommt.
|
||||
|
||||
Über *Nutzer aufnehmen* auf der Spielerseite kommt jemand dazu; das Guthaben wird von seinem
|
||||
Konto gebucht, nicht vom Konto des Gründers. Wer die Gruppe verlässt, wird über das ✕ an seinem
|
||||
Namen ausgetragen - **verbrauchtes Guthaben gibt es dabei nicht zurück**, denn bis dahin hat er
|
||||
den CC genutzt. Für eine Fehleingabe ist das Storno der Buchung der richtige Weg.
|
||||
|
||||
Ausgetretene bleiben als „früher: …" sichtbar, damit nachvollziehbar ist, wer den CC wann genutzt
|
||||
hat. Der letzte verbliebene Nutzer lässt sich nicht austragen - dann wird der CC deaktiviert.
|
||||
|
||||
Ein Redesign nimmt die Gruppe mit: Wer zum Zeitpunkt des Redesigns dabei war, ist es danach auch.
|
||||
Bezahlt wird es von dem, der es beantragt.
|
||||
|
||||
## Schnellstart
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Gründe ohne Anspruch auf kostenlose Rückkehr, und der Tod im Rollenspiel.
|
||||
|
||||
ALTER TYPE "InactiveReason" ADD VALUE IF NOT EXISTS 'NO_FREE_CLAIM';
|
||||
ALTER TYPE "InactiveReason" ADD VALUE IF NOT EXISTS 'DEAD';
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Mehrere Spieler können sich einen CC teilen.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "CharacterMember" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"characterId" INTEGER NOT NULL,
|
||||
"playerId" INTEGER NOT NULL,
|
||||
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"leftAt" TIMESTAMP(3),
|
||||
"paidWith" "CreditKind",
|
||||
"note" TEXT,
|
||||
"createdById" INTEGER,
|
||||
CONSTRAINT "CharacterMember_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "CharacterMember_characterId_playerId_key"
|
||||
ON "CharacterMember"("characterId", "playerId");
|
||||
CREATE INDEX IF NOT EXISTS "CharacterMember_playerId_idx" ON "CharacterMember"("playerId");
|
||||
CREATE INDEX IF NOT EXISTS "CharacterMember_characterId_idx" ON "CharacterMember"("characterId");
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "CharacterMember" ADD CONSTRAINT "CharacterMember_characterId_fkey"
|
||||
FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "CharacterMember" ADD CONSTRAINT "CharacterMember_playerId_fkey"
|
||||
FOREIGN KEY ("playerId") REFERENCES "Player"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- Bestehende CCs bekommen ihren Ersteller als erstes Mitglied. Ohne das
|
||||
-- taucht nach dem Update kein CC mehr beim Spieler auf, da die Zugehörigkeit
|
||||
-- ab jetzt über diese Tabelle läuft.
|
||||
INSERT INTO "CharacterMember" ("characterId", "playerId", "joinedAt", "createdById")
|
||||
SELECT c."id", c."playerId", c."createdAt", c."createdById"
|
||||
FROM "Character" c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "CharacterMember" m
|
||||
WHERE m."characterId" = c."id" AND m."playerId" = c."playerId"
|
||||
);
|
||||
@@ -35,9 +35,16 @@ enum CharacterStatus {
|
||||
}
|
||||
|
||||
enum InactiveReason {
|
||||
/// Vom Server geputzt, weil der Spieler länger weg war. Kommt gratis zurück.
|
||||
PLAYER_INACTIVITY
|
||||
/// Durch ein Redesign ersetzt; das Original existiert nicht mehr.
|
||||
REWORKED
|
||||
/// Sonstiger Grund ohne Folgen, etwa eine zeitweise entfernte Einheit.
|
||||
OTHER
|
||||
/// Entzogen ohne Anspruch auf kostenlose Rückkehr.
|
||||
NO_FREE_CLAIM
|
||||
/// Im Rollenspiel gestorben. Rückkehr nur durch einen Administrator.
|
||||
DEAD
|
||||
}
|
||||
|
||||
enum ProductTarget {
|
||||
@@ -127,6 +134,7 @@ model Player {
|
||||
unmatched UnmatchedPackage[]
|
||||
forumThreads ForumThread[]
|
||||
aliases PlayerAlias[]
|
||||
memberships CharacterMember[]
|
||||
|
||||
@@index([displayName])
|
||||
}
|
||||
@@ -214,6 +222,7 @@ model Character {
|
||||
|
||||
ledgerEntries CreditLedger[]
|
||||
forumThreads ForumThread[]
|
||||
members CharacterMember[]
|
||||
|
||||
createdById Int?
|
||||
createdBy User? @relation("CharacterCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
@@ -384,3 +393,31 @@ model PlayerAlias {
|
||||
@@unique([type, value])
|
||||
@@index([playerId])
|
||||
}
|
||||
|
||||
/// Wer einen CC nutzt. Die meisten CCs haben genau ein Mitglied, manche teilen
|
||||
/// sich mehrere Spieler ("Gruppen-CC"). Für jeden gelten dieselben Regeln: der
|
||||
/// Platz kostet ein eigenes Guthaben, und wer aussteigt bekommt nichts zurück.
|
||||
model CharacterMember {
|
||||
id Int @id @default(autoincrement())
|
||||
characterId Int
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
playerId Int
|
||||
player Player @relation(fields: [playerId], references: [id], onDelete: Cascade)
|
||||
|
||||
joinedAt DateTime @default(now())
|
||||
/// Gesetzt, sobald der Spieler ausgestiegen ist. Der Eintrag bleibt stehen,
|
||||
/// damit nachvollziehbar ist, wer den CC wann genutzt hat.
|
||||
leftAt DateTime?
|
||||
|
||||
/// Womit der Platz bezahlt wurde. Leer beim Ersteller, dessen Buchung am
|
||||
/// CC selbst hängt.
|
||||
paidWith CreditKind?
|
||||
note String?
|
||||
|
||||
createdById Int?
|
||||
|
||||
/// Ein Spieler kann demselben CC nicht zweimal angehören.
|
||||
@@unique([characterId, playerId])
|
||||
@@index([playerId])
|
||||
@@index([characterId])
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { CreditKind, InactiveReason } from '@prisma/client';
|
||||
import { requireEditor } from '../auth/guard.js';
|
||||
import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js';
|
||||
import {
|
||||
addMember,
|
||||
createCharacter,
|
||||
deactivateCharacter,
|
||||
listMembers,
|
||||
removeMember,
|
||||
reviveDeadCharacter,
|
||||
reactivateFree,
|
||||
reactivatePaid,
|
||||
redesignCharacter,
|
||||
@@ -88,7 +92,12 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(
|
||||
z.object({
|
||||
reason: z.enum([InactiveReason.PLAYER_INACTIVITY, InactiveReason.OTHER]),
|
||||
reason: z.enum([
|
||||
InactiveReason.PLAYER_INACTIVITY,
|
||||
InactiveReason.OTHER,
|
||||
InactiveReason.NO_FREE_CLAIM,
|
||||
InactiveReason.DEAD,
|
||||
]),
|
||||
note: optionalNote,
|
||||
}),
|
||||
request.body,
|
||||
@@ -110,6 +119,54 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reactivatePaid(id, body.useKind, contextOf(request), body.note);
|
||||
});
|
||||
|
||||
/**
|
||||
* Rückkehr eines im Rollenspiel gestorbenen CCs. Ausnahmeentscheidung,
|
||||
* deshalb Administratoren vorbehalten und nur mit Begründung.
|
||||
*/
|
||||
app.post('/api/characters/:id/revive-dead', { preHandler: requireAdmin }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(
|
||||
z.object({
|
||||
reason: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500),
|
||||
useKind: reviveKind.optional(),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
return reviveDeadCharacter(id, body, contextOf(request));
|
||||
});
|
||||
|
||||
// --- Gruppen-CCs ---
|
||||
|
||||
app.get('/api/characters/:id/members', { preHandler: requireAuth }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
return { items: await listMembers(id) };
|
||||
});
|
||||
|
||||
app.post('/api/characters/:id/members', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(
|
||||
z.object({
|
||||
playerId: z.number().int().positive(),
|
||||
useKind: z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]),
|
||||
note: optionalNote,
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
return addMember(id, body.playerId, { useKind: body.useKind, note: body.note }, contextOf(request));
|
||||
});
|
||||
|
||||
app.delete('/api/characters/:id/members/:playerId', { preHandler: requireEditor }, async (request) => {
|
||||
const params = parseWith(
|
||||
z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
playerId: z.coerce.number().int().positive(),
|
||||
}),
|
||||
request.params,
|
||||
);
|
||||
const body = parseWith(z.object({ note: optionalNote }), request.body ?? {});
|
||||
return removeMember(params.id, params.playerId, body.note, contextOf(request));
|
||||
});
|
||||
|
||||
app.post('/api/characters/:id/revert', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(
|
||||
|
||||
@@ -19,6 +19,12 @@ export interface CharacterResult {
|
||||
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.
|
||||
@@ -55,6 +61,9 @@ export async function createCharacter(
|
||||
status: CharacterStatus.ACTIVE,
|
||||
lineageId: randomUUID(),
|
||||
createdById: ctx.actorId,
|
||||
// Der Ersteller ist zugleich das erste Mitglied. Die Zugehörigkeit
|
||||
// läuft durchgehend über diese Tabelle, auch bei Einzel-CCs.
|
||||
members: { create: [{ playerId, createdById: ctx.actorId }] },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -115,6 +124,13 @@ export async function redesignCharacter(
|
||||
data: { status: CharacterStatus.INACTIVE, inactiveReason: InactiveReason.REWORKED },
|
||||
});
|
||||
|
||||
// Ein Gruppen-CC bleibt nach dem Redesign eine Gruppe: alle, die zum
|
||||
// Zeitpunkt des Redesigns dabei waren, sind es auch danach.
|
||||
const bisherige = await tx.characterMember.findMany({
|
||||
where: { characterId: old.id, leftAt: null },
|
||||
select: { playerId: true, paidWith: true },
|
||||
});
|
||||
|
||||
const created = await tx.character.create({
|
||||
data: {
|
||||
playerId: old.playerId,
|
||||
@@ -124,6 +140,13 @@ export async function redesignCharacter(
|
||||
lineageId: old.lineageId,
|
||||
predecessorId: old.id,
|
||||
createdById: ctx.actorId,
|
||||
members: {
|
||||
create: bisherige.map((mitglied) => ({
|
||||
playerId: mitglied.playerId,
|
||||
paidWith: mitglied.paidWith,
|
||||
createdById: ctx.actorId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -182,6 +205,9 @@ export async function deactivateCharacter(
|
||||
'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({
|
||||
@@ -208,10 +234,10 @@ export async function deactivateCharacter(
|
||||
}
|
||||
|
||||
/**
|
||||
* Gratis-Reaktivierung. Möglich, solange der CC nicht redesignt wurde. Der
|
||||
* Grund des Verschwindens spielt dabei keine Rolle - ob Spieler-Inaktivität
|
||||
* oder eine zeitweise entfernte Einheit: der CC existiert unverändert weiter
|
||||
* und kostet deshalb kein Guthaben.
|
||||
* 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,
|
||||
@@ -222,10 +248,16 @@ export async function reactivateFree(
|
||||
if (existing.status === CharacterStatus.ACTIVE) {
|
||||
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
|
||||
}
|
||||
if (existing.inactiveReason === InactiveReason.REWORKED) {
|
||||
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 wurde redesignt und existiert im Original nicht mehr. Seine Reaktivierung kostet ein Guthaben.',
|
||||
'Dieser CC hat keinen Anspruch auf kostenlose Rückkehr. Seine Reaktivierung kostet ein Guthaben.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -265,7 +297,13 @@ export async function reactivatePaid(
|
||||
if (existing.status === CharacterStatus.ACTIVE) {
|
||||
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
|
||||
}
|
||||
if (existing.inactiveReason !== InactiveReason.REWORKED) {
|
||||
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.',
|
||||
@@ -420,13 +458,242 @@ export async function revertCharacter(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Die CCs eines Spielers. Maßgeblich ist die Mitgliedschaft, nicht wer den CC
|
||||
* angelegt hat - sonst fehlten Gruppen-CCs, denen der Spieler beigetreten ist.
|
||||
*/
|
||||
export async function listCharacters(playerId: number) {
|
||||
return prisma.character.findMany({
|
||||
where: { playerId },
|
||||
where: { members: { some: { playerId, leftAt: null } } },
|
||||
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
|
||||
include: {
|
||||
predecessor: { select: { id: true, name: true } },
|
||||
successors: { select: { id: true, name: true } },
|
||||
members: {
|
||||
orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }],
|
||||
include: { player: { select: { id: true, displayName: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Bringt einen im Rollenspiel gestorbenen CC zurück. Anders als die übrigen
|
||||
* Wege ist das eine Ausnahmeentscheidung: sie bleibt Administratoren
|
||||
* vorbehalten, verlangt eine Begründung und hält beides im Protokoll fest.
|
||||
*
|
||||
* Ob dabei ein Guthaben fließt, entscheidet der Administrator - ein Tod ist
|
||||
* weder automatisch ein Rework noch ein Serverputz.
|
||||
*/
|
||||
export async function reviveDeadCharacter(
|
||||
characterId: number,
|
||||
input: { reason: string; useKind?: ReviveKind | undefined },
|
||||
ctx: ActionContext,
|
||||
): Promise<CharacterResult> {
|
||||
const existing = await loadCharacter(characterId);
|
||||
|
||||
if (existing.status === CharacterStatus.ACTIVE) {
|
||||
throw conflict('ALREADY_ACTIVE', 'Dieser CC ist bereits aktiv');
|
||||
}
|
||||
if (existing.inactiveReason !== InactiveReason.DEAD) {
|
||||
throw conflict(
|
||||
'NOT_DEAD',
|
||||
'Dieser CC ist nicht als im Rollenspiel gestorben vermerkt - bitte den normalen Weg nutzen',
|
||||
);
|
||||
}
|
||||
if (!input.reason.trim()) {
|
||||
throw badRequest('NEED_REASON', 'Für die Rückkehr eines toten CCs wird eine Begründung gebraucht');
|
||||
}
|
||||
if (input.useKind !== undefined && !REVIVE_KINDS.includes(input.useKind)) {
|
||||
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art');
|
||||
}
|
||||
|
||||
const character = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.character.update({
|
||||
where: { id: characterId },
|
||||
data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null },
|
||||
});
|
||||
|
||||
if (input.useKind !== undefined) {
|
||||
await tx.creditLedger.create({
|
||||
data: {
|
||||
playerId: updated.playerId,
|
||||
kind: input.useKind,
|
||||
amount: -1,
|
||||
source: CreditSource.CONSUME,
|
||||
groupId: randomUUID(),
|
||||
characterId: updated.id,
|
||||
note: `Rückkehr nach Tod: ${input.reason}`,
|
||||
createdById: ctx.actorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await 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) };
|
||||
}
|
||||
|
||||
|
||||
// --- 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];
|
||||
|
||||
/**
|
||||
* Nimmt einen weiteren Spieler in einen CC auf. Für ihn gilt dasselbe wie für
|
||||
* einen eigenen CC: der Platz kostet ein Guthaben. Ob über ein CC-Paket oder
|
||||
* ein Redesign, entscheidet der Nutzer - beide Wege kommen vor.
|
||||
*/
|
||||
export async function addMember(
|
||||
characterId: number,
|
||||
playerId: number,
|
||||
input: { useKind: JoinKind; note?: string | undefined },
|
||||
ctx: ActionContext,
|
||||
): Promise<CharacterResult & { memberCount: number }> {
|
||||
const character = await loadCharacter(characterId);
|
||||
|
||||
if (character.status !== CharacterStatus.ACTIVE) {
|
||||
throw conflict('NOT_ACTIVE', 'Einem inaktiven CC kann niemand beitreten');
|
||||
}
|
||||
if (!JOIN_KINDS.includes(input.useKind)) {
|
||||
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für einen Beitritt');
|
||||
}
|
||||
|
||||
const player = await prisma.player.findUnique({ where: { id: playerId }, select: { id: true } });
|
||||
if (!player) throw notFound('Spieler');
|
||||
|
||||
const vorhanden = await prisma.characterMember.findUnique({
|
||||
where: { characterId_playerId: { characterId, playerId } },
|
||||
});
|
||||
if (vorhanden && vorhanden.leftAt === null) {
|
||||
throw conflict('ALREADY_MEMBER', 'Dieser Spieler nutzt den CC bereits');
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (vorhanden) {
|
||||
// Wiedereintritt: der alte Eintrag wird fortgeschrieben, damit die
|
||||
// Zugehörigkeit nicht doppelt in der Liste steht.
|
||||
await tx.characterMember.update({
|
||||
where: { id: vorhanden.id },
|
||||
data: {
|
||||
leftAt: null,
|
||||
joinedAt: new Date(),
|
||||
paidWith: input.useKind,
|
||||
note: input.note ?? null,
|
||||
createdById: ctx.actorId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.characterMember.create({
|
||||
data: {
|
||||
characterId,
|
||||
playerId,
|
||||
paidWith: input.useKind,
|
||||
note: input.note ?? null,
|
||||
createdById: ctx.actorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.creditLedger.create({
|
||||
data: {
|
||||
playerId,
|
||||
kind: input.useKind,
|
||||
amount: -1,
|
||||
source: CreditSource.CONSUME,
|
||||
groupId: randomUUID(),
|
||||
characterId,
|
||||
note: input.note ?? `Beitritt zu "${character.name}"`,
|
||||
createdById: ctx.actorId,
|
||||
},
|
||||
});
|
||||
|
||||
await recordAudit(tx, ctx, {
|
||||
action: 'character.member_added',
|
||||
targetType: 'character',
|
||||
targetId: characterId,
|
||||
details: { playerId, name: character.name, useKind: input.useKind },
|
||||
});
|
||||
});
|
||||
|
||||
const [balances, memberCount] = await Promise.all([
|
||||
getBalances(playerId),
|
||||
prisma.characterMember.count({ where: { characterId, leftAt: null } }),
|
||||
]);
|
||||
|
||||
return { character, balances, overdrawn: isOverdrawn(balances), 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 } } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { after, before, describe, it } from 'node:test';
|
||||
import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||
|
||||
type Characters = typeof import('../src/services/characters.js');
|
||||
type Credits = typeof import('../src/services/credits.js');
|
||||
type Db = typeof import('../src/db.js');
|
||||
|
||||
let database: TestDatabase;
|
||||
let characters: Characters;
|
||||
let credits: Credits;
|
||||
let db: Db;
|
||||
let ctx: { actorId: number; actorName: string };
|
||||
|
||||
/** Ein Spieler mit einem CC-Paket im Rücken. */
|
||||
async function spielerMitPaket(name: string, pakete = 1) {
|
||||
const player = await db.prisma.player.create({ data: { displayName: name } });
|
||||
for (let i = 0; i < pakete; i += 1) {
|
||||
await credits.bookCcPackage(player.id, ctx);
|
||||
}
|
||||
return player.id;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
database = await startTestDatabase(54336);
|
||||
process.env.DATABASE_URL = database.url;
|
||||
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
db = await import('../src/db.js');
|
||||
characters = await import('../src/services/characters.js');
|
||||
credits = await import('../src/services/credits.js');
|
||||
|
||||
const user = await db.prisma.user.create({
|
||||
data: { username: 'gruppenchef', passwordHash: 'x', role: 'ADMIN' },
|
||||
});
|
||||
ctx = { actorId: user.id, actorName: user.username };
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await db.prisma.$disconnect();
|
||||
await database.stop();
|
||||
});
|
||||
|
||||
describe('Gruppen-CCs', () => {
|
||||
it('führt den Ersteller als erstes Mitglied', async () => {
|
||||
const playerId = await spielerMitPaket('Gründer');
|
||||
const { character } = await characters.createCharacter(playerId, { name: 'Die Bande' }, ctx);
|
||||
|
||||
const mitglieder = await characters.listMembers(character.id);
|
||||
assert.equal(mitglieder.length, 1);
|
||||
assert.equal(mitglieder[0]?.playerId, playerId);
|
||||
assert.equal(mitglieder[0]?.leftAt, null);
|
||||
});
|
||||
|
||||
it('kostet jeden Beitretenden ein eigenes Guthaben', async () => {
|
||||
const gruender = await spielerMitPaket('Anführer');
|
||||
const zweiter = await spielerMitPaket('Mitstreiter');
|
||||
const { character } = await characters.createCharacter(gruender, { name: 'Zweierbande' }, ctx);
|
||||
|
||||
const vorher = await credits.getBalances(zweiter);
|
||||
const result = await characters.addMember(character.id, zweiter, { useKind: 'CC' }, ctx);
|
||||
|
||||
assert.equal(result.memberCount, 2);
|
||||
assert.equal(result.balances.CC, vorher.CC - 1, 'sein eigenes CC-Guthaben ist weg');
|
||||
|
||||
// Der Gründer zahlt nicht doppelt.
|
||||
const gruenderStand = await credits.getBalances(gruender);
|
||||
assert.equal(gruenderStand.CC, 0, 'nur sein eigener CC, nichts zusätzlich');
|
||||
});
|
||||
|
||||
it('lässt den Beitritt auch über ein Redesign zu', async () => {
|
||||
const gruender = await spielerMitPaket('Chef');
|
||||
const zweiter = await spielerMitPaket('Nachzügler');
|
||||
const { character } = await characters.createCharacter(gruender, { name: 'Redesign-Bande' }, ctx);
|
||||
|
||||
// Sein CC-Guthaben hat er schon anderweitig gebraucht, das Redesign nicht.
|
||||
await characters.createCharacter(zweiter, { name: 'Eigener CC' }, ctx);
|
||||
const result = await characters.addMember(
|
||||
character.id,
|
||||
zweiter,
|
||||
{ useKind: 'REDESIGN_PAID' },
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert.equal(result.balances.REDESIGN_PAID, 0, 'das Redesign ist verbraucht');
|
||||
assert.equal(result.memberCount, 2);
|
||||
});
|
||||
|
||||
it('nimmt denselben Spieler nicht zweimal auf', async () => {
|
||||
const gruender = await spielerMitPaket('Doppelt', 2);
|
||||
const { character } = await characters.createCharacter(gruender, { name: 'Einmalig' }, ctx);
|
||||
|
||||
await assert.rejects(
|
||||
() => characters.addMember(character.id, gruender, { useKind: 'CC' }, ctx),
|
||||
(error: Error & { code?: string }) => error.code === 'ALREADY_MEMBER',
|
||||
);
|
||||
});
|
||||
|
||||
it('trägt einen Spieler wieder aus, ohne Guthaben zurückzugeben', async () => {
|
||||
const gruender = await spielerMitPaket('Bleibt');
|
||||
const aussteiger = await spielerMitPaket('Geht');
|
||||
const { character } = await characters.createCharacter(gruender, { name: 'Wechselbande' }, ctx);
|
||||
await characters.addMember(character.id, aussteiger, { useKind: 'CC' }, ctx);
|
||||
|
||||
const vorAustritt = await credits.getBalances(aussteiger);
|
||||
const result = await characters.removeMember(character.id, aussteiger, 'Gruppe verlassen', ctx);
|
||||
|
||||
assert.equal(result.memberCount, 1);
|
||||
const nachAustritt = await credits.getBalances(aussteiger);
|
||||
assert.deepEqual(nachAustritt, vorAustritt, 'der Platz war bezahlt und bleibt es');
|
||||
});
|
||||
|
||||
it('zeigt den CC nach dem Austritt nicht mehr beim Aussteiger', async () => {
|
||||
const gruender = await spielerMitPaket('Kern');
|
||||
const kurz = await spielerMitPaket('Kurzzeitig');
|
||||
const { character } = await characters.createCharacter(gruender, { name: 'Durchlauf' }, ctx);
|
||||
await characters.addMember(character.id, kurz, { useKind: 'CC' }, ctx);
|
||||
|
||||
const waehrend = await characters.listCharacters(kurz);
|
||||
assert.ok(waehrend.some((c) => c.id === character.id), 'während der Mitgliedschaft sichtbar');
|
||||
|
||||
await characters.removeMember(character.id, kurz, undefined, ctx);
|
||||
|
||||
const danach = await characters.listCharacters(kurz);
|
||||
assert.equal(danach.some((c) => c.id === character.id), false, 'danach nicht mehr');
|
||||
|
||||
// Beim Gründer bleibt er stehen.
|
||||
const beimGruender = await characters.listCharacters(gruender);
|
||||
assert.ok(beimGruender.some((c) => c.id === character.id));
|
||||
});
|
||||
|
||||
it('verweigert den Austritt des letzten Nutzers', async () => {
|
||||
const allein = await spielerMitPaket('Einzelgänger');
|
||||
const { character } = await characters.createCharacter(allein, { name: 'Solo' }, ctx);
|
||||
|
||||
await assert.rejects(
|
||||
() => characters.removeMember(character.id, allein, undefined, ctx),
|
||||
(error: Error & { code?: string }) => error.code === 'LAST_MEMBER',
|
||||
);
|
||||
});
|
||||
|
||||
it('nimmt die Gruppe ins Redesign mit', async () => {
|
||||
const gruender = await spielerMitPaket('Alt-Chef');
|
||||
const zweiter = await spielerMitPaket('Alt-Mitglied');
|
||||
const { character } = await characters.createCharacter(gruender, { name: 'Vorher' }, ctx);
|
||||
await characters.addMember(character.id, zweiter, { useKind: 'CC' }, ctx);
|
||||
|
||||
const result = await characters.redesignCharacter(
|
||||
character.id,
|
||||
{ name: 'Nachher', useKind: 'REDESIGN_PAID' },
|
||||
ctx,
|
||||
);
|
||||
|
||||
const neue = await characters.listMembers(result.character.id);
|
||||
assert.equal(neue.length, 2, 'beide sind weiterhin dabei');
|
||||
assert.deepEqual(
|
||||
neue.map((m) => m.playerId).sort(),
|
||||
[gruender, zweiter].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('lässt niemanden einem inaktiven CC beitreten', async () => {
|
||||
const gruender = await spielerMitPaket('Inaktiv-Chef');
|
||||
const spaet = await spielerMitPaket('Zu spät');
|
||||
const { character } = await characters.createCharacter(gruender, { name: 'Geschlossen' }, ctx);
|
||||
await characters.deactivateCharacter(character.id, 'PLAYER_INACTIVITY', ctx);
|
||||
|
||||
await assert.rejects(
|
||||
() => characters.addMember(character.id, spaet, { useKind: 'CC' }, ctx),
|
||||
(error: Error & { code?: string }) => error.code === 'NOT_ACTIVE',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tote und entzogene CCs', () => {
|
||||
it('lässt einen toten CC nicht auf dem normalen Weg zurückkehren', async () => {
|
||||
const playerId = await spielerMitPaket('Trauernder');
|
||||
const { character } = await characters.createCharacter(playerId, { name: 'Gefallener' }, ctx);
|
||||
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Im RP erschossen');
|
||||
|
||||
await assert.rejects(
|
||||
() => characters.reactivateFree(character.id, ctx),
|
||||
(error: Error & { code?: string }) => error.code === 'IS_DEAD',
|
||||
);
|
||||
await assert.rejects(
|
||||
() => characters.reactivatePaid(character.id, 'CC', ctx),
|
||||
(error: Error & { code?: string }) => error.code === 'IS_DEAD',
|
||||
);
|
||||
});
|
||||
|
||||
it('verlangt für den Tod eine Begründung', async () => {
|
||||
const playerId = await spielerMitPaket('Knapp');
|
||||
const { character } = await characters.createCharacter(playerId, { name: 'Ohne Grund' }, ctx);
|
||||
|
||||
await assert.rejects(
|
||||
() => characters.deactivateCharacter(character.id, 'DEAD', ctx),
|
||||
(error: Error & { code?: string }) => error.code === 'NEED_NOTE',
|
||||
);
|
||||
});
|
||||
|
||||
it('holt einen toten CC auf Entscheidung zurück, mit Begründung', async () => {
|
||||
const playerId = await spielerMitPaket('Auferstanden');
|
||||
const { character } = await characters.createCharacter(playerId, { name: 'Zurück' }, ctx);
|
||||
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Im RP gestorben');
|
||||
|
||||
const vorher = await credits.getBalances(playerId);
|
||||
const result = await characters.reviveDeadCharacter(
|
||||
character.id,
|
||||
{ reason: 'Rückholung nach Absprache mit der Leitung' },
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert.equal(result.character.status, 'ACTIVE');
|
||||
assert.equal(result.character.inactiveReason, null);
|
||||
assert.deepEqual(result.balances, vorher, 'ohne Guthaben-Art kostet es nichts');
|
||||
});
|
||||
|
||||
it('kann die Rückkehr auch kostenpflichtig machen', async () => {
|
||||
const playerId = await spielerMitPaket('Zahlt', 2);
|
||||
const { character } = await characters.createCharacter(playerId, { name: 'Teuer' }, ctx);
|
||||
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Gestorben');
|
||||
|
||||
const vorher = await credits.getBalances(playerId);
|
||||
const result = await characters.reviveDeadCharacter(
|
||||
character.id,
|
||||
{ reason: 'Gegen ein Guthaben zurückgeholt', useKind: 'CC' },
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert.equal(result.balances.CC, vorher.CC - 1);
|
||||
});
|
||||
|
||||
it('verlangt auch bei der Rückkehr eine Begründung', async () => {
|
||||
const playerId = await spielerMitPaket('Wortkarg');
|
||||
const { character } = await characters.createCharacter(playerId, { name: 'Stumm' }, ctx);
|
||||
await characters.deactivateCharacter(character.id, 'DEAD', ctx, 'Gestorben');
|
||||
|
||||
await assert.rejects(
|
||||
() => characters.reviveDeadCharacter(character.id, { reason: ' ' }, ctx),
|
||||
(error: Error & { code?: string }) => error.code === 'NEED_REASON',
|
||||
);
|
||||
});
|
||||
|
||||
it('ein entzogener CC kommt nicht gratis zurück, aber gegen Guthaben', async () => {
|
||||
const playerId = await spielerMitPaket('Entzogen', 2);
|
||||
const { character } = await characters.createCharacter(playerId, { name: 'Weggenommen' }, ctx);
|
||||
await characters.deactivateCharacter(character.id, 'NO_FREE_CLAIM', ctx, 'Regelverstoss');
|
||||
|
||||
await assert.rejects(
|
||||
() => characters.reactivateFree(character.id, ctx),
|
||||
(error: Error & { code?: string }) => error.code === 'NOT_FREE',
|
||||
);
|
||||
|
||||
const result = await characters.reactivatePaid(character.id, 'CC', ctx);
|
||||
assert.equal(result.character.status, 'ACTIVE');
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,21 @@ export const REASON_LABEL: Record<InactiveReason, string> = {
|
||||
PLAYER_INACTIVITY: 'Spieler-Inaktivität',
|
||||
REWORKED: 'Redesignt',
|
||||
OTHER: 'Anderer Grund',
|
||||
NO_FREE_CLAIM: 'Entzogen',
|
||||
DEAD: 'Im RP gestorben',
|
||||
};
|
||||
|
||||
/**
|
||||
* Welchen Weg die Rückkehr nimmt. Gratis, solange der CC unverändert
|
||||
* weiterexistiert; bezahlt, wenn er ersetzt oder entzogen wurde; und eine
|
||||
* Entscheidung des Administrators, wenn er im Rollenspiel gestorben ist.
|
||||
*/
|
||||
export function rueckkehrArt(reason: InactiveReason | null): 'gratis' | 'bezahlt' | 'admin' {
|
||||
if (reason === 'DEAD') return 'admin';
|
||||
if (reason === 'REWORKED' || reason === 'NO_FREE_CLAIM') return 'bezahlt';
|
||||
return 'gratis';
|
||||
}
|
||||
|
||||
export const PLAN_LABEL: Record<ImportPlan, string> = {
|
||||
BOOK: 'Wird gebucht',
|
||||
CREATE_PLAYER_AND_BOOK: 'Spieler anlegen + buchen',
|
||||
|
||||
+18
-1
@@ -8,7 +8,12 @@ export type CreditSource =
|
||||
| 'CONSUME'
|
||||
| 'REVERSAL';
|
||||
export type CharacterStatus = 'ACTIVE' | 'INACTIVE';
|
||||
export type InactiveReason = 'PLAYER_INACTIVITY' | 'REWORKED' | 'OTHER';
|
||||
export type InactiveReason =
|
||||
| 'PLAYER_INACTIVITY'
|
||||
| 'REWORKED'
|
||||
| 'OTHER'
|
||||
| 'NO_FREE_CLAIM'
|
||||
| 'DEAD';
|
||||
export type ProductTarget = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'IGNORE';
|
||||
|
||||
export interface SessionUser {
|
||||
@@ -85,6 +90,17 @@ export interface CharacterRef {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CharacterMember {
|
||||
id: number;
|
||||
characterId: number;
|
||||
playerId: number;
|
||||
player: { id: number; displayName: string; steamId?: string | null };
|
||||
joinedAt: string;
|
||||
leftAt: string | null;
|
||||
paidWith: CreditKind | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
id: number;
|
||||
playerId: number;
|
||||
@@ -97,6 +113,7 @@ export interface Character {
|
||||
predecessorId: number | null;
|
||||
predecessor: CharacterRef | null;
|
||||
successors: CharacterRef[];
|
||||
members?: CharacterMember[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
+415
-31
@@ -6,16 +6,26 @@ import AliasBox from '../components/AliasBox';
|
||||
import Modal from '../components/Modal';
|
||||
import Notice from '../components/Notice';
|
||||
import { ApiError, api } from '../lib/api';
|
||||
import { KIND_LABEL, REASON_LABEL, SOURCE_LABEL, formatAmount, formatDate } from '../lib/format';
|
||||
import {
|
||||
KIND_LABEL,
|
||||
REASON_LABEL,
|
||||
SOURCE_LABEL,
|
||||
formatAmount,
|
||||
formatDate,
|
||||
rueckkehrArt,
|
||||
} from '../lib/format';
|
||||
import { useSession } from '../lib/session';
|
||||
import type {
|
||||
Character,
|
||||
PlayerSummary,
|
||||
CreditKind,
|
||||
LedgerEntry,
|
||||
PlayerDetail as Detail,
|
||||
ScrapeSummary,
|
||||
} from '../lib/types';
|
||||
|
||||
type DeactivateReason = 'PLAYER_INACTIVITY' | 'OTHER' | 'NO_FREE_CLAIM' | 'DEAD';
|
||||
|
||||
type DialogState =
|
||||
| { type: 'none' }
|
||||
| { type: 'newCharacter' }
|
||||
@@ -26,7 +36,9 @@ type DialogState =
|
||||
| { type: 'reverse'; entry: LedgerEntry }
|
||||
| { type: 'editPlayer' }
|
||||
| { type: 'deletePlayer' }
|
||||
| { type: 'addCredit'; kind: 'cc-package' | 'redesign-package' | 'kulanz' };
|
||||
| { type: 'addCredit'; kind: 'cc-package' | 'redesign-package' | 'kulanz' }
|
||||
| { type: 'addMember'; character: Character }
|
||||
| { type: 'reviveDead'; character: Character };
|
||||
|
||||
export default function PlayerDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -236,10 +248,22 @@ export default function PlayerDetail() {
|
||||
<li key={character.id} className="cc-item">
|
||||
<div>
|
||||
<strong>{character.name}</strong>
|
||||
{character.members && character.members.filter((m) => !m.leftAt).length > 1 && (
|
||||
<span className="badge badge-new">
|
||||
Gruppen-CC · {character.members.filter((m) => !m.leftAt).length}
|
||||
</span>
|
||||
)}
|
||||
{character.predecessor && (
|
||||
<span className="muted"> · Nachfolger von {character.predecessor.name}</span>
|
||||
)}
|
||||
{character.loreText && <p className="lore">{character.loreText}</p>}
|
||||
<MemberList
|
||||
character={character}
|
||||
currentPlayerId={playerId}
|
||||
canEdit={editor}
|
||||
onChanged={() => void load()}
|
||||
onError={setError}
|
||||
/>
|
||||
<span className="muted small">Seit {formatDate(character.createdAt)}</span>
|
||||
</div>
|
||||
{editor && (
|
||||
@@ -251,6 +275,13 @@ export default function PlayerDetail() {
|
||||
>
|
||||
Redesign
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-small"
|
||||
onClick={() => setDialog({ type: 'addMember', character })}
|
||||
>
|
||||
Nutzer aufnehmen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-small btn-ghost"
|
||||
@@ -280,13 +311,21 @@ export default function PlayerDetail() {
|
||||
) : (
|
||||
<ul className="cc-list">
|
||||
{inactive.map((character) => {
|
||||
// Nur ein redesignter CC existiert im Original nicht mehr.
|
||||
const free = character.inactiveReason !== 'REWORKED';
|
||||
const art = rueckkehrArt(character.inactiveReason);
|
||||
const free = art === 'gratis';
|
||||
return (
|
||||
<li key={character.id} className="cc-item is-inactive">
|
||||
<div>
|
||||
<strong>{character.name}</strong>
|
||||
<span className={`badge ${free ? 'badge-free' : 'badge-paid'}`}>
|
||||
<span
|
||||
className={`badge ${
|
||||
art === 'gratis'
|
||||
? 'badge-free'
|
||||
: art === 'admin'
|
||||
? 'badge-dead'
|
||||
: 'badge-paid'
|
||||
}`}
|
||||
>
|
||||
{character.inactiveReason
|
||||
? REASON_LABEL[character.inactiveReason]
|
||||
: 'Unbekannt'}
|
||||
@@ -516,6 +555,43 @@ export default function PlayerDetail() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{dialog.type === 'addMember' && (
|
||||
<AddMemberModal
|
||||
character={dialog.character}
|
||||
busy={busy}
|
||||
onClose={() => setDialog({ type: 'none' })}
|
||||
onSubmit={(values) =>
|
||||
void run(
|
||||
() =>
|
||||
api(`/api/characters/${dialog.character.id}/members`, {
|
||||
method: 'POST',
|
||||
body: values,
|
||||
}),
|
||||
`Nutzer zu "${dialog.character.name}" hinzugefügt.`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dialog.type === 'reviveDead' && (
|
||||
<ReviveDeadModal
|
||||
character={dialog.character}
|
||||
balances={detail.balances}
|
||||
busy={busy}
|
||||
onClose={() => setDialog({ type: 'none' })}
|
||||
onSubmit={(values) =>
|
||||
void run(
|
||||
() =>
|
||||
api(`/api/characters/${dialog.character.id}/revive-dead`, {
|
||||
method: 'POST',
|
||||
body: values,
|
||||
}),
|
||||
`"${dialog.character.name}" ist zurück.`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dialog.type === 'addCredit' && (
|
||||
<AddCreditModal
|
||||
kind={dialog.kind}
|
||||
@@ -855,10 +931,12 @@ function DeactivateModal({
|
||||
character: Character;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (reason: 'PLAYER_INACTIVITY' | 'OTHER', note: string) => void;
|
||||
onSubmit: (reason: DeactivateReason, note: string) => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState<'PLAYER_INACTIVITY' | 'OTHER'>('PLAYER_INACTIVITY');
|
||||
const [reason, setReason] = useState<DeactivateReason>('PLAYER_INACTIVITY');
|
||||
const [note, setNote] = useState('');
|
||||
// Ein Tod im Rollenspiel ist eine Ausnahme und will erklärt werden.
|
||||
const notizPflicht = reason === 'DEAD';
|
||||
|
||||
return (
|
||||
<Modal title={`"${character.name}" deaktivieren`} onClose={onClose}>
|
||||
@@ -868,53 +946,70 @@ function DeactivateModal({
|
||||
onSubmit(reason, note);
|
||||
}}
|
||||
>
|
||||
<Notice kind="info">
|
||||
Der CC bleibt unverändert und kommt später in beiden Fällen gratis zurück. Nur ein
|
||||
Redesign macht ihn kostenpflichtig - das läuft aber über die Redesign-Aktion.
|
||||
</Notice>
|
||||
<Notice kind="info">Der Grund entscheidet, wie der CC später zurückkommt.</Notice>
|
||||
|
||||
<div className="radio-group">
|
||||
<label className="radio">
|
||||
{(
|
||||
[
|
||||
[
|
||||
'PLAYER_INACTIVITY',
|
||||
'Spieler-Inaktivität',
|
||||
'vom Server geputzt - kommt gratis zurück',
|
||||
],
|
||||
[
|
||||
'OTHER',
|
||||
'Anderer Grund',
|
||||
'etwa eine vorübergehend entfernte Einheit - kommt gratis zurück',
|
||||
],
|
||||
[
|
||||
'NO_FREE_CLAIM',
|
||||
'Entzogen',
|
||||
'kein Anspruch auf kostenlose Rückkehr - kostet ein Guthaben',
|
||||
],
|
||||
[
|
||||
'DEAD',
|
||||
'Im Rollenspiel gestorben',
|
||||
'Rückkehr nur durch einen Administrator, mit Begründung',
|
||||
],
|
||||
] as const
|
||||
).map(([wert, label, hinweis]) => (
|
||||
<label key={wert} className="radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="reason"
|
||||
checked={reason === 'PLAYER_INACTIVITY'}
|
||||
onChange={() => setReason('PLAYER_INACTIVITY')}
|
||||
checked={reason === wert}
|
||||
onChange={() => setReason(wert)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Spieler-Inaktivität</strong>
|
||||
<span className="muted"> - vom Server geputzt, weil der Spieler länger weg war</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="reason"
|
||||
checked={reason === 'OTHER'}
|
||||
onChange={() => setReason('OTHER')}
|
||||
/>
|
||||
<span>
|
||||
<strong>Anderer Grund</strong>
|
||||
<span className="muted"> - etwa eine vorübergehend entfernte Einheit</span>
|
||||
<strong>{label}</strong>
|
||||
<span className="muted"> - {hinweis}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label htmlFor="deactivate-note">
|
||||
Notiz {reason === 'OTHER' ? '(empfohlen)' : '(optional)'}
|
||||
Notiz {notizPflicht ? '(Pflicht)' : reason === 'OTHER' ? '(empfohlen)' : '(optional)'}
|
||||
</label>
|
||||
<input
|
||||
id="deactivate-note"
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
placeholder="z. B. Einheit bis März entfernt"
|
||||
placeholder={
|
||||
notizPflicht ? 'Was ist im Rollenspiel passiert?' : 'z. B. Einheit bis März entfernt'
|
||||
}
|
||||
required={notizPflicht}
|
||||
/>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || (notizPflicht && !note.trim())}
|
||||
>
|
||||
{busy ? 'Speichere …' : 'Deaktivieren'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1112,3 +1207,292 @@ function DeletePlayerModal({
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wer einen CC nutzt. Bei einem gewöhnlichen CC ist das eine Zeile und wird
|
||||
* ausgeblendet; erst bei mehreren Nutzern lohnt die Liste.
|
||||
*/
|
||||
function MemberList({
|
||||
character,
|
||||
currentPlayerId,
|
||||
canEdit,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
character: Character;
|
||||
currentPlayerId: number;
|
||||
canEdit: boolean;
|
||||
onChanged: () => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const alle = character.members ?? [];
|
||||
const aktiv = alle.filter((m) => !m.leftAt);
|
||||
const ehemalig = alle.filter((m) => m.leftAt);
|
||||
|
||||
if (aktiv.length <= 1 && ehemalig.length === 0) return null;
|
||||
|
||||
const austragen = async (playerId: number, name: string) => {
|
||||
if (!window.confirm(`"${name}" aus "${character.name}" austragen?`)) return;
|
||||
try {
|
||||
await api(`/api/characters/${character.id}/members/${playerId}`, { method: 'DELETE' });
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
onError(err instanceof ApiError ? err.message : 'Austragen fehlgeschlagen');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="member-list">
|
||||
{aktiv.map((mitglied) => (
|
||||
<span key={mitglied.id} className="member-chip">
|
||||
{mitglied.playerId === currentPlayerId ? (
|
||||
<strong>{mitglied.player.displayName}</strong>
|
||||
) : (
|
||||
<Link to={`/players/${mitglied.playerId}`}>{mitglied.player.displayName}</Link>
|
||||
)}
|
||||
{canEdit && aktiv.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="member-remove"
|
||||
title="Austragen"
|
||||
onClick={() => void austragen(mitglied.playerId, mitglied.player.displayName)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{ehemalig.length > 0 && (
|
||||
<span className="muted small">
|
||||
früher: {ehemalig.map((m) => m.player.displayName).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddMemberModal({
|
||||
character,
|
||||
busy,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
character: Character;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (values: { playerId: number; useKind: CreditKind; note?: string }) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<PlayerSummary[]>([]);
|
||||
const [gewaehlt, setGewaehlt] = useState<PlayerSummary | null>(null);
|
||||
const [useKind, setUseKind] = useState<CreditKind>('CC');
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ q: query, take: '20' });
|
||||
const data = await api<{ items: PlayerSummary[] }>(`/api/players?${params}`);
|
||||
setResults(data.items.filter((p) => p.id !== character.playerId));
|
||||
} catch {
|
||||
// Ein Suchfehler blockiert den Dialog nicht.
|
||||
}
|
||||
}, 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, character.playerId]);
|
||||
|
||||
return (
|
||||
<Modal title={`Nutzer zu "${character.name}" hinzufügen`} onClose={onClose}>
|
||||
<Notice kind="info">
|
||||
Für den neuen Nutzer gilt dasselbe wie für einen eigenen CC: der Platz kostet ihn ein
|
||||
eigenes Guthaben. Ob über sein CC-Paket oder ein Redesign, entscheidest du hier.
|
||||
</Notice>
|
||||
|
||||
{!gewaehlt ? (
|
||||
<>
|
||||
<label htmlFor="member-search">Spieler suchen</label>
|
||||
<input
|
||||
id="member-search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<ul className="cc-list" style={{ marginTop: '0.75rem' }}>
|
||||
{results.map((player) => (
|
||||
<li key={player.id} className="cc-item">
|
||||
<div>
|
||||
<strong>{player.displayName}</strong>
|
||||
<p className="muted small">
|
||||
CC {player.balances.CC} · Redesign {player.balances.redesignTotal}
|
||||
</p>
|
||||
</div>
|
||||
<div className="cc-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-small btn-primary"
|
||||
onClick={() => setGewaehlt(player)}
|
||||
>
|
||||
Auswählen
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{results.length === 0 && <p className="muted">Kein Treffer.</p>}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit({
|
||||
playerId: gewaehlt.id,
|
||||
useKind,
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Notice kind="info">
|
||||
<strong>{gewaehlt.displayName}</strong> - CC-Guthaben {gewaehlt.balances.CC}, Redesign{' '}
|
||||
{gewaehlt.balances.redesignTotal}
|
||||
</Notice>
|
||||
|
||||
<label>Womit bezahlt er seinen Platz?</label>
|
||||
<div className="radio-group">
|
||||
{(
|
||||
[
|
||||
['CC', 'CC-Guthaben', gewaehlt.balances.CC],
|
||||
['REDESIGN_KULANZ', 'Kulanz-Redesign', gewaehlt.balances.REDESIGN_KULANZ],
|
||||
['REDESIGN_PAID', 'Bezahltes Redesign', gewaehlt.balances.REDESIGN_PAID],
|
||||
] as const
|
||||
).map(([wert, label, stand]) => (
|
||||
<label key={wert} className="radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="joinKind"
|
||||
checked={useKind === wert}
|
||||
onChange={() => setUseKind(wert)}
|
||||
/>
|
||||
<span>
|
||||
{label}
|
||||
<span className={stand < 1 ? 'neg' : 'muted'}> ({stand} verfügbar)</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label htmlFor="member-note">Notiz (optional)</label>
|
||||
<input
|
||||
id="member-note"
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
placeholder="z. B. Forums-Antrag oder Absprache"
|
||||
/>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setGewaehlt(null)}>
|
||||
Anderen wählen
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
{busy ? 'Speichere …' : 'Aufnehmen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviveDeadModal({
|
||||
character,
|
||||
balances,
|
||||
busy,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
character: Character;
|
||||
balances: { CC: number; REDESIGN_PAID: number; REDESIGN_KULANZ: number };
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (values: { reason: string; useKind?: CreditKind }) => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState('');
|
||||
const [kostet, setKostet] = useState(false);
|
||||
const [useKind, setUseKind] = useState<CreditKind>('CC');
|
||||
|
||||
return (
|
||||
<Modal title={`"${character.name}" zurückholen`} onClose={onClose}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit({ reason: reason.trim(), ...(kostet ? { useKind } : {}) });
|
||||
}}
|
||||
>
|
||||
<Notice kind="warn">
|
||||
Dieser CC ist im Rollenspiel gestorben. Seine Rückkehr ist eine Ausnahme und wird mit
|
||||
Begründung im Protokoll festgehalten.
|
||||
{character.inactiveNote && (
|
||||
<>
|
||||
<br />
|
||||
<span className="muted">Vermerkt war: {character.inactiveNote}</span>
|
||||
</>
|
||||
)}
|
||||
</Notice>
|
||||
|
||||
<label htmlFor="revive-reason">Begründung</label>
|
||||
<input
|
||||
id="revive-reason"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="z. B. Rückholung nach Absprache mit der Leitung"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={kostet}
|
||||
onChange={(event) => setKostet(event.target.checked)}
|
||||
/>
|
||||
<span>Ein Guthaben dafür verbrauchen</span>
|
||||
</label>
|
||||
|
||||
{kostet && (
|
||||
<div className="radio-group">
|
||||
{(
|
||||
[
|
||||
['CC', 'CC-Guthaben', balances.CC],
|
||||
['REDESIGN_KULANZ', 'Kulanz-Redesign', balances.REDESIGN_KULANZ],
|
||||
['REDESIGN_PAID', 'Bezahltes Redesign', balances.REDESIGN_PAID],
|
||||
] as const
|
||||
).map(([wert, label, stand]) => (
|
||||
<label key={wert} className="radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="reviveKind"
|
||||
checked={useKind === wert}
|
||||
onChange={() => setUseKind(wert)}
|
||||
/>
|
||||
<span>
|
||||
{label}
|
||||
<span className={stand < 1 ? 'neg' : 'muted'}> ({stand} verfügbar)</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn btn-danger" disabled={busy || !reason.trim()}>
|
||||
{busy ? 'Speichere …' : 'Zurückholen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -957,3 +957,42 @@ textarea {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* --- Gruppen-CCs --- */
|
||||
|
||||
.member-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
.member-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.member-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0 0 0 0.1rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.member-remove:hover {
|
||||
color: var(--negative);
|
||||
}
|
||||
|
||||
.badge-dead {
|
||||
border-color: var(--negative);
|
||||
color: var(--negative);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user