diff --git a/README.md b/README.md index 91734c1..048be8b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/server/prisma/migrations/20250105000000_tote_ccs/migration.sql b/server/prisma/migrations/20250105000000_tote_ccs/migration.sql new file mode 100644 index 0000000..95d872e --- /dev/null +++ b/server/prisma/migrations/20250105000000_tote_ccs/migration.sql @@ -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'; diff --git a/server/prisma/migrations/20250106000000_gruppen_ccs/migration.sql b/server/prisma/migrations/20250106000000_gruppen_ccs/migration.sql new file mode 100644 index 0000000..1ac1a21 --- /dev/null +++ b/server/prisma/migrations/20250106000000_gruppen_ccs/migration.sql @@ -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" +); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index d4a746e..a946f22 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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]) +} diff --git a/server/src/routes/characters.routes.ts b/server/src/routes/characters.routes.ts index 03f8a2b..fc42661 100644 --- a/server/src/routes/characters.routes.ts +++ b/server/src/routes/characters.routes.ts @@ -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 { 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 { 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( diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index d715a17..6dc7cf5 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -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 { + 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 { + 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 } } }, + }); +} diff --git a/server/test/gruppen.test.ts b/server/test/gruppen.test.ts new file mode 100644 index 0000000..99c61a6 --- /dev/null +++ b/server/test/gruppen.test.ts @@ -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'); + }); +}); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 0464622..d3a7456 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -19,8 +19,21 @@ export const REASON_LABEL: Record = { 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 = { BOOK: 'Wird gebucht', CREATE_PLAYER_AND_BOOK: 'Spieler anlegen + buchen', diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index be66f74..dab75ce 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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; } diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index b9711d3..de8c1d3 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -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() {
  • {character.name} + {character.members && character.members.filter((m) => !m.leftAt).length > 1 && ( + + Gruppen-CC · {character.members.filter((m) => !m.leftAt).length} + + )} {character.predecessor && ( · Nachfolger von {character.predecessor.name} )} {character.loreText &&

    {character.loreText}

    } + void load()} + onError={setError} + /> Seit {formatDate(character.createdAt)}
    {editor && ( @@ -251,6 +275,13 @@ export default function PlayerDetail() { > Redesign + - @@ -1112,3 +1207,292 @@ function DeletePlayerModal({ ); } + +/** + * 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 ( +
    + {aktiv.map((mitglied) => ( + + {mitglied.playerId === currentPlayerId ? ( + {mitglied.player.displayName} + ) : ( + {mitglied.player.displayName} + )} + {canEdit && aktiv.length > 1 && ( + + )} + + ))} + + {ehemalig.length > 0 && ( + + früher: {ehemalig.map((m) => m.player.displayName).join(', ')} + + )} +
    + ); +} + +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([]); + const [gewaehlt, setGewaehlt] = useState(null); + const [useKind, setUseKind] = useState('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 ( + + + 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. + + + {!gewaehlt ? ( + <> + + setQuery(event.target.value)} + autoFocus + /> + +
      + {results.map((player) => ( +
    • +
      + {player.displayName} +

      + CC {player.balances.CC} · Redesign {player.balances.redesignTotal} +

      +
      +
      + +
      +
    • + ))} + {results.length === 0 &&

      Kein Treffer.

      } +
    + + ) : ( +
    { + event.preventDefault(); + onSubmit({ + playerId: gewaehlt.id, + useKind, + ...(note.trim() ? { note: note.trim() } : {}), + }); + }} + > + + {gewaehlt.displayName} - CC-Guthaben {gewaehlt.balances.CC}, Redesign{' '} + {gewaehlt.balances.redesignTotal} + + + +
    + {( + [ + ['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]) => ( + + ))} +
    + + + setNote(event.target.value)} + placeholder="z. B. Forums-Antrag oder Absprache" + /> + +
    + + +
    +
    + )} +
    + ); +} + +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('CC'); + + return ( + +
    { + event.preventDefault(); + onSubmit({ reason: reason.trim(), ...(kostet ? { useKind } : {}) }); + }} + > + + Dieser CC ist im Rollenspiel gestorben. Seine Rückkehr ist eine Ausnahme und wird mit + Begründung im Protokoll festgehalten. + {character.inactiveNote && ( + <> +
    + Vermerkt war: {character.inactiveNote} + + )} +
    + + + setReason(event.target.value)} + placeholder="z. B. Rückholung nach Absprache mit der Leitung" + autoFocus + required + /> + + + + {kostet && ( +
    + {( + [ + ['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]) => ( + + ))} +
    + )} + +
    + + +
    +
    +
    + ); +} diff --git a/web/src/styles.css b/web/src/styles.css index 6286c74..c582a58 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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); +}