Einzelne Gruppenmitglieder, Austritt per Redesign, Ladebalken beim Abgleich
Bei einem CC mit mehreren Nutzern gibt es zwei Ebenen, und bisher liess sich nur die obere bedienen. Jetzt beide: Ein Spieler kann seinen Anteil redesignen und steigt damit aus - er bekommt einen eigenen CC, die Gruppe laeuft unberuehrt weiter. Fuer ihn ist es ein Tausch wie jeder andere: der Gruppenplatz geht weg, der eigene CC kommt an seine Stelle, es kommt keiner hinzu. Wohin er gewechselt ist, bleibt an der Mitgliedschaft festgehalten und steht bei den frueheren Nutzern. Das Redesign der ganzen Gruppe, bei dem alle mitwandern, gibt es unveraendert daneben. Ebenso koennen einzelne Nutzer ausfallen, ohne die uebrigen zu beruehren: bei einem Trio kann einer im Rollenspiel sterben, waehrend die anderen weiterspielen, und ebenso koennen alle nacheinander wegfallen. Grund und Begruendung haengen jetzt auch an der Mitgliedschaft. Die Rueckkehr folgt denselben Regeln wie beim eigenen CC - geputzt gratis, entzogen gegen Guthaben, gestorben nur durch einen Administrator mit Begruendung. Wer ausgefallen ist, sieht seinen CC weiter auf der Spielerseite; sonst gaebe es nichts, worueber er zurueckkehren koennte. Ein freiwilliger Austritt bleibt davon getrennt: dort gibt es nichts zurueckzuholen, ein Wiedereintritt kostet neu. Der GExtension-Abgleich hat denselben Ladebalken wie der Forumslauf bekommen, fuer alle Spieler wie fuer einzelne, samt Protokoll. Beide teilen sich jetzt dieselbe Anzeige. Dabei aufgefallen: Zeitplan und Handklick konnten denselben Abgleich doppelt starten und haetten dieselben Profile zweimal abgerufen. Die Sperre sitzt jetzt im Lauf selbst statt im Starter, damit sie fuer beide Wege gilt.
This commit is contained in:
@@ -524,10 +524,19 @@ 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.
|
||||
*
|
||||
* Mit dabei sind auch CCs, bei denen seine Mitgliedschaft ruht: wer geputzt
|
||||
* wurde oder im Rollenspiel gestorben ist, kann zurückkehren und muss den CC
|
||||
* dafür sehen. Wer freiwillig ausgetreten ist, sieht ihn nicht mehr - dort
|
||||
* gibt es nichts zurückzuholen, ein Wiedereintritt kostet neu.
|
||||
*/
|
||||
export async function listCharacters(playerId: number) {
|
||||
return prisma.character.findMany({
|
||||
where: { members: { some: { playerId, leftAt: null } } },
|
||||
where: {
|
||||
members: {
|
||||
some: { playerId, OR: [{ leftAt: null }, { inactiveReason: { not: null } }] },
|
||||
},
|
||||
},
|
||||
orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
|
||||
include: {
|
||||
predecessor: { select: { id: true, name: true } },
|
||||
@@ -539,7 +548,10 @@ export async function listCharacters(playerId: number) {
|
||||
},
|
||||
members: {
|
||||
orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }],
|
||||
include: { player: { select: { id: true, displayName: true } } },
|
||||
include: {
|
||||
player: { select: { id: true, displayName: true } },
|
||||
successor: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -800,6 +812,283 @@ export async function addMember(
|
||||
return { character, balances, overdrawn: isOverdrawn(balances), memberCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Redesignt den Anteil eines Spielers an einem Gruppen-CC.
|
||||
*
|
||||
* Er steigt damit aus und bekommt einen eigenen CC an dessen Stelle; die
|
||||
* Gruppe läuft unberührt weiter. Für ihn ist es ein Redesign wie jedes
|
||||
* andere: ein Platz wird getauscht, keiner kommt hinzu.
|
||||
*
|
||||
* Ist er der letzte Nutzer, gibt es keine Gruppe mehr, die weiterlaufen
|
||||
* könnte - dann ist es das gewöhnliche Redesign des ganzen CCs, und der läuft
|
||||
* über redesignCharacter(). Ein Gruppen-Redesign, bei dem alle mitwandern,
|
||||
* ebenfalls: das ist die Sache des ganzen CCs, nicht die eines Mitglieds.
|
||||
*/
|
||||
export async function redesignMembership(
|
||||
characterId: number,
|
||||
playerId: number,
|
||||
input: {
|
||||
name: string;
|
||||
loreText?: string | undefined;
|
||||
useKind: RedesignKind;
|
||||
note?: string | undefined;
|
||||
},
|
||||
ctx: ActionContext,
|
||||
): Promise<CharacterResult & { leftGroup: boolean }> {
|
||||
const gruppe = await loadCharacter(characterId);
|
||||
|
||||
if (gruppe.status !== CharacterStatus.ACTIVE) {
|
||||
throw conflict('NOT_ACTIVE', 'Nur ein aktiver CC kann redesignt werden');
|
||||
}
|
||||
if (!REDESIGN_KINDS.includes(input.useKind)) {
|
||||
throw badRequest('INVALID_KIND', 'Ein Redesign kostet bezahltes oder Kulanz-Redesign-Guthaben');
|
||||
}
|
||||
const name = input.name.trim();
|
||||
if (!name) throw badRequest('NO_NAME', 'Für den neuen CC wird ein Name gebraucht');
|
||||
|
||||
const mitglied = await prisma.characterMember.findUnique({
|
||||
where: { characterId_playerId: { characterId, playerId } },
|
||||
});
|
||||
if (!mitglied || mitglied.leftAt !== null) {
|
||||
throw notFound('Mitgliedschaft');
|
||||
}
|
||||
|
||||
const verbleibend = await prisma.characterMember.count({
|
||||
where: { characterId, leftAt: null },
|
||||
});
|
||||
if (verbleibend <= 1) {
|
||||
throw conflict(
|
||||
'LAST_MEMBER',
|
||||
'Dieser Spieler ist der letzte Nutzer. Dann ist es ein Redesign des ganzen CCs, kein Austritt.',
|
||||
);
|
||||
}
|
||||
|
||||
const neuerCC = await prisma.$transaction(async (tx) => {
|
||||
const erstellt = await tx.character.create({
|
||||
data: {
|
||||
playerId,
|
||||
name,
|
||||
loreText: input.loreText ?? null,
|
||||
status: CharacterStatus.ACTIVE,
|
||||
// Eine eigene Linie: der Gruppen-CC lebt weiter, hier beginnt ein
|
||||
// neuer Strang. Woher er kommt, steht an der Mitgliedschaft.
|
||||
predecessorId: gruppe.id,
|
||||
createdById: ctx.actorId,
|
||||
members: { create: [{ playerId, paidWith: input.useKind, createdById: ctx.actorId }] },
|
||||
},
|
||||
});
|
||||
|
||||
// Kein Grund für eine Rückkehr: sein Platz wurde getauscht, nicht
|
||||
// entzogen. Wohin er gegangen ist, steht im Nachfolger - und genau daran
|
||||
// hängt auch, dass der Gruppen-CC nicht länger als seiner gilt.
|
||||
await tx.characterMember.update({
|
||||
where: { id: mitglied.id },
|
||||
data: {
|
||||
leftAt: new Date(),
|
||||
successorId: erstellt.id,
|
||||
note: input.note ?? `Anteil redesignt zu "${erstellt.name}"`,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.creditLedger.create({
|
||||
data: {
|
||||
playerId,
|
||||
kind: input.useKind,
|
||||
amount: -1,
|
||||
source: CreditSource.CONSUME,
|
||||
groupId: randomUUID(),
|
||||
characterId: erstellt.id,
|
||||
note: input.note ?? `Redesign aus "${gruppe.name}" heraus: ${erstellt.name}`,
|
||||
createdById: ctx.actorId,
|
||||
},
|
||||
});
|
||||
|
||||
await assertSlotsReichen(tx, playerId, 'Dieses Redesign');
|
||||
|
||||
await recordAudit(tx, ctx, {
|
||||
action: 'character.membership_redesigned',
|
||||
targetType: 'character',
|
||||
targetId: erstellt.id,
|
||||
details: {
|
||||
playerId,
|
||||
from: gruppe.name,
|
||||
fromId: gruppe.id,
|
||||
to: erstellt.name,
|
||||
useKind: input.useKind,
|
||||
remaining: verbleibend - 1,
|
||||
},
|
||||
});
|
||||
|
||||
return erstellt;
|
||||
});
|
||||
|
||||
const balances = await getBalances(playerId);
|
||||
return { character: neuerCC, balances, overdrawn: isOverdrawn(balances), leftGroup: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt eine einzelne Mitgliedschaft inaktiv - der CC selbst bleibt, wie er
|
||||
* ist. Bei einem Gruppen-CC kann genau ein Spieler wegfallen, ohne dass die
|
||||
* anderen etwas davon merken: einer stirbt im Rollenspiel, einer wird
|
||||
* geputzt, die übrigen spielen weiter.
|
||||
*
|
||||
* Für die Rückkehr gelten dieselben Regeln wie bei einem eigenen CC - gratis,
|
||||
* bezahlt oder nur durch einen Administrator.
|
||||
*/
|
||||
export async function deactivateMember(
|
||||
characterId: number,
|
||||
playerId: number,
|
||||
reason: InactiveReason,
|
||||
ctx: ActionContext,
|
||||
note?: string,
|
||||
): Promise<{ memberCount: number }> {
|
||||
if (reason === InactiveReason.REWORKED) {
|
||||
throw badRequest(
|
||||
'USE_REDESIGN',
|
||||
'Ein Rework entsteht über das Redesign des Anteils, nicht über eine Deaktivierung',
|
||||
);
|
||||
}
|
||||
if (reason === InactiveReason.DEAD && !note?.trim()) {
|
||||
throw badRequest('NEED_NOTE', 'Für einen Tod im Rollenspiel wird eine Begründung gebraucht');
|
||||
}
|
||||
|
||||
const mitglied = await prisma.characterMember.findUnique({
|
||||
where: { characterId_playerId: { characterId, playerId } },
|
||||
include: { character: { select: { name: true } } },
|
||||
});
|
||||
if (!mitglied || mitglied.leftAt !== null) {
|
||||
throw notFound('Mitgliedschaft');
|
||||
}
|
||||
|
||||
const verbleibend = await prisma.characterMember.count({
|
||||
where: { characterId, leftAt: null },
|
||||
});
|
||||
if (verbleibend <= 1) {
|
||||
throw conflict(
|
||||
'LAST_MEMBER',
|
||||
'Das ist der letzte Nutzer dieses CCs. Dann ist der ganze CC betroffen, nicht nur eine Mitgliedschaft.',
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.characterMember.update({
|
||||
where: { id: mitglied.id },
|
||||
data: {
|
||||
leftAt: new Date(),
|
||||
inactiveReason: reason,
|
||||
inactiveNote: note?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
await recordAudit(tx, ctx, {
|
||||
action: 'character.member_deactivated',
|
||||
targetType: 'character',
|
||||
targetId: characterId,
|
||||
details: { playerId, name: mitglied.character.name, reason, note: note ?? null },
|
||||
});
|
||||
});
|
||||
|
||||
return { memberCount: verbleibend - 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt eine einzelne Mitgliedschaft zurück. Die Regeln sind dieselben wie bei
|
||||
* einem eigenen CC: was unverändert weiterbesteht, kommt gratis zurück; was
|
||||
* ersetzt oder entzogen wurde, kostet; und über einen im Rollenspiel
|
||||
* Gestorbenen entscheidet ein Administrator.
|
||||
*/
|
||||
export async function reactivateMember(
|
||||
characterId: number,
|
||||
playerId: number,
|
||||
input: { useKind?: ReviveKind | undefined; reason?: string | undefined },
|
||||
ctx: ActionContext,
|
||||
): Promise<{ memberCount: number }> {
|
||||
const gruppe = await loadCharacter(characterId);
|
||||
if (gruppe.status !== CharacterStatus.ACTIVE) {
|
||||
throw conflict('NOT_ACTIVE', 'Dieser CC ist selbst inaktiv - erst ihn zurückholen');
|
||||
}
|
||||
|
||||
const mitglied = await prisma.characterMember.findUnique({
|
||||
where: { characterId_playerId: { characterId, playerId } },
|
||||
});
|
||||
if (!mitglied) throw notFound('Mitgliedschaft');
|
||||
if (mitglied.leftAt === null) {
|
||||
throw conflict('ALREADY_MEMBER', 'Dieser Spieler nutzt den CC bereits');
|
||||
}
|
||||
|
||||
const grund = mitglied.inactiveReason;
|
||||
|
||||
// Dass hierfür ein Administrator nötig ist, sichert die Route ab - so wie
|
||||
// bei einem im Rollenspiel gestorbenen CC. Hier steht nur, was der Vorgang
|
||||
// selbst verlangt.
|
||||
if (grund === InactiveReason.DEAD && !input.reason?.trim()) {
|
||||
throw badRequest(
|
||||
'NEED_REASON',
|
||||
'Für die Rückkehr eines Gestorbenen wird eine Begründung gebraucht',
|
||||
);
|
||||
}
|
||||
|
||||
// Ein Austritt ohne Grund war freiwillig: der Platz ist weg und muss neu
|
||||
// bezahlt werden. Dafür gibt es den gewöhnlichen Beitritt.
|
||||
if (grund === null) {
|
||||
throw conflict(
|
||||
'WAS_VOLUNTARY',
|
||||
'Dieser Spieler ist ausgetreten, nicht ausgefallen. Eine Rückkehr ist ein neuer Beitritt und kostet ein Guthaben.',
|
||||
);
|
||||
}
|
||||
|
||||
const kostenpflichtig =
|
||||
grund === InactiveReason.REWORKED || grund === InactiveReason.NO_FREE_CLAIM;
|
||||
if (kostenpflichtig && input.useKind === undefined) {
|
||||
throw badRequest('NEED_KIND', 'Diese Rückkehr kostet ein Guthaben - bitte angeben, welches');
|
||||
}
|
||||
if (input.useKind !== undefined && !REVIVE_KINDS.includes(input.useKind)) {
|
||||
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art');
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.characterMember.update({
|
||||
where: { id: mitglied.id },
|
||||
data: { leftAt: null, inactiveReason: null, inactiveNote: null },
|
||||
});
|
||||
|
||||
if (input.useKind !== undefined) {
|
||||
await tx.creditLedger.create({
|
||||
data: {
|
||||
playerId,
|
||||
kind: input.useKind,
|
||||
amount: -1,
|
||||
source: CreditSource.CONSUME,
|
||||
groupId: randomUUID(),
|
||||
characterId,
|
||||
note: input.reason ?? `Rückkehr zu "${gruppe.name}"`,
|
||||
createdById: ctx.actorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await assertSlotsReichen(tx, playerId, 'Die Rückkehr');
|
||||
|
||||
await recordAudit(tx, ctx, {
|
||||
action: 'character.member_reactivated',
|
||||
targetType: 'character',
|
||||
targetId: characterId,
|
||||
details: {
|
||||
playerId,
|
||||
name: gruppe.name,
|
||||
previousReason: grund,
|
||||
useKind: input.useKind ?? null,
|
||||
reason: input.reason ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const memberCount = await prisma.characterMember.count({
|
||||
where: { characterId, leftAt: null },
|
||||
});
|
||||
return { memberCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trägt einen Spieler wieder aus. Das verbrauchte Guthaben bleibt verbraucht -
|
||||
* wer aussteigt, hat den CC bis dahin genutzt. Für eine Fehleingabe ist die
|
||||
@@ -851,6 +1140,10 @@ 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 } } },
|
||||
include: {
|
||||
player: { select: { id: true, displayName: true, steamId: true } },
|
||||
successor: { select: { id: true, name: true } },
|
||||
predecessor: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user