From e78a57e35e643153614af8b1ca88fa7ff4347953 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Sun, 20 Sep 2026 23:03:22 +0200 Subject: [PATCH] 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. --- README.md | 33 +- .../20250105000000_tote_ccs/migration.sql | 4 + .../20250106000000_gruppen_ccs/migration.sql | 39 ++ server/prisma/schema.prisma | 37 ++ server/src/routes/characters.routes.ts | 61 ++- server/src/services/characters.ts | 283 ++++++++++- server/test/gruppen.test.ts | 258 ++++++++++ web/src/lib/format.ts | 13 + web/src/lib/types.ts | 19 +- web/src/pages/PlayerDetail.tsx | 460 ++++++++++++++++-- web/src/styles.css | 39 ++ 11 files changed, 1193 insertions(+), 53 deletions(-) create mode 100644 server/prisma/migrations/20250105000000_tote_ccs/migration.sql create mode 100644 server/prisma/migrations/20250106000000_gruppen_ccs/migration.sql create mode 100644 server/test/gruppen.test.ts 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); +}