Slots durchsetzen, Abgleiche nach Zeitplan, Kaufdatum im Kontobuch

Ein CC-Paket ist ein Platz fuer einen gleichzeitig aktiven CC - ein Redesign
tauscht einen CC aus, statt einen Platz hinzuzufuegen. Beim Gruppen-Beitritt
galt das seit dem letzten Stand, an anderer Stelle aber noch nicht: ein
redesignter CC liess sich mit einem Redesign-Guthaben zurueckholen, waehrend
sein Nachfolger aktiv blieb. Zwei aktive CCs auf ein einziges Paket.

Geprueft wird das jetzt an einer Stelle statt an jedem Weg einzeln, fuer
Erschaffen, Redesign, Reaktivierung, Rueckkehr und Beitritt. Der Massstab ist
die Deckung, nicht der Kontostand: jeder aktive CC braucht eine eigene Buchung
im CC-Topf. Ein bewusst ueberzogenes Konto bleibt damit moeglich - die Buchung
ist da, nur das Guthaben fehlt, und das Minus steht sichtbar auf der
Spielerseite.

Beide Abgleiche laufen jetzt von allein: die Kaeufe alle zwei Stunden, bei
Spielern ohne Kauf in den letzten drei Jahren alle sechs, das Forum einmal
nachts. Das spart den Grossteil der Anfragen, denn ruhende Konten sind mit der
Zeit die Mehrheit. Gebucht wird dabei nichts - neue Pakete landen in der
Vorschau, neue Antraege in der Liste. Der Stand steht in der Datenbank, damit
ein Neustart nicht alles erneut anstoesst. Unter Verwaltung steht, wann
zuletzt gelaufen wurde; auf der Uebersicht, wie viele Pakete auf eine Buchung
warten und bei wem.

Ausserdem:

- Die Guthaben-Historie zeigt bei Importen den Kauf im Shop, nicht den
  Zeitpunkt der Eingabe hier. Bereits gebuchte Importe werden nachgetragen.
- Forums-Antraege tragen ihr Datum, im Forum ausgelesen statt geraten.
- Die Hinweisbox ueber den Antraegen beim Spieler faellt weg.
- Die Aktionsspalte der Nutzerverwaltung war per display:flex aus dem
  Tabellenlayout gefallen, daher die versetzte Trennlinie.
- Die Zeitzone steht jetzt im Compose-Stack, sonst laege "nachts um vier"
  nach UTC und im Sommer zwei Stunden daneben.
This commit is contained in:
2026-09-21 12:35:36 +02:00
parent 293d882cb4
commit dbde5b7d11
26 changed files with 974 additions and 22 deletions
+67
View File
@@ -5,6 +5,7 @@ import {
CreditSource,
InactiveReason,
type Character,
type Prisma,
} from '@prisma/client';
import { prisma } from '../db.js';
import { badRequest, conflict, notFound } from '../errors.js';
@@ -37,6 +38,60 @@ function isOverdrawn(balances: Balances): boolean {
return balances.CC < 0 || balances.REDESIGN_PAID < 0 || balances.REDESIGN_KULANZ < 0;
}
/**
* Die Slot-Regel.
*
* Ein CC-Paket ist ein Platz für einen gleichzeitig aktiven CC. Wer zwei
* Pakete hat, führt zwei CCs gleichzeitig - egal, wie viele Redesigns
* dazwischen liegen. Ein Redesign **tauscht** einen CC aus, es schafft keinen
* zusätzlichen Platz; fünf Millionen Redesigns auf ein Paket bleiben ein CC.
*
* Geprüft wird nicht der Kontostand, sondern die Deckung: **jeder aktive CC
* braucht eine eigene Buchung im CC-Topf.** Das ist der Unterschied zwischen
* einem bewusst überzogenen Konto - eine Buchung ist da, das Guthaben fehlt
* noch, und das Minus steht sichtbar auf der Spielerseite - und einem CC, der
* einfach zusätzlich aktiv wird, ohne dass irgendwo ein Platz dafür gebucht
* wurde. Das Erste ist gewollt, das Zweite wäre ein Platz aus dem Nichts.
*
* Geprüft wird an einer Stelle, statt an jedem Weg einzeln: erschaffen,
* redesignen, reaktivieren, zurückholen, einem Gruppen-CC beitreten.
*/
async function assertSlotsReichen(
tx: Prisma.TransactionClient,
playerId: number,
was: string,
): Promise<void> {
const [eintraege, belegt] = await Promise.all([
tx.creditLedger.findMany({
where: { playerId, kind: CreditKind.CC },
select: { amount: true, source: true, reversalOf: { select: { source: true } } },
}),
tx.characterMember.count({
where: { playerId, leftAt: null, character: { status: CharacterStatus.ACTIVE } },
}),
]);
// Verbräuche zählen, die nicht storniert wurden. Ein Storno trägt die Art
// der Zeile, die es zurücknimmt - nur so lässt sich ein zurückgenommener
// Platz von einem zurückgenommenen Paket unterscheiden.
let gebuchtePlaetze = 0;
for (const eintrag of eintraege) {
const zweck =
eintrag.source === CreditSource.REVERSAL ? eintrag.reversalOf?.source : eintrag.source;
if (zweck === CreditSource.CONSUME) gebuchtePlaetze -= eintrag.amount;
}
if (belegt > gebuchtePlaetze) {
throw conflict(
'NO_FREE_SLOT',
`${was} ist nicht möglich: Der Platz dieses CCs ist inzwischen von einem anderen belegt. ` +
'Ein CC-Paket ist ein Platz für einen gleichzeitig aktiven CC, und ein Redesign tauscht ' +
'einen CC aus, statt einen Platz hinzuzufügen. Entweder den anderen CC deaktivieren - ' +
'oder ein CC-Guthaben einsetzen, das einen eigenen Platz bucht.',
);
}
}
async function loadCharacter(id: number): Promise<Character> {
const character = await prisma.character.findUnique({ where: { id } });
if (!character) throw notFound('CC');
@@ -80,6 +135,8 @@ export async function createCharacter(
},
});
await assertSlotsReichen(tx, playerId, 'Ein neuer CC');
await recordAudit(tx, ctx, {
action: 'character.create',
targetType: 'character',
@@ -163,6 +220,8 @@ export async function redesignCharacter(
},
});
await assertSlotsReichen(tx, old.playerId, 'Dieses Redesign');
await recordAudit(tx, ctx, {
action: 'character.redesign',
targetType: 'character',
@@ -267,6 +326,8 @@ export async function reactivateFree(
data: { status: CharacterStatus.ACTIVE, inactiveReason: null, inactiveNote: null },
});
await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs');
await recordAudit(tx, ctx, {
action: 'character.reactivate_free',
targetType: 'character',
@@ -332,6 +393,8 @@ export async function reactivatePaid(
},
});
await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs');
await recordAudit(tx, ctx, {
action: 'character.reactivate_paid',
targetType: 'character',
@@ -535,6 +598,8 @@ export async function reviveDeadCharacter(
});
}
await assertSlotsReichen(tx, updated.playerId, 'Die Rückkehr dieses CCs');
await recordAudit(tx, ctx, {
action: 'character.revive_dead',
targetType: 'character',
@@ -711,6 +776,8 @@ export async function addMember(
},
});
await assertSlotsReichen(tx, playerId, 'Der Beitritt');
await recordAudit(tx, ctx, {
action: 'character.member_added',
targetType: 'character',