Jede Liste gehoert einem Spieler, CC-Guthaben wird umgewandelt
Ein Gruppen-CC wird fuer jeden Nutzer einzeln gerechnet: fuer den einen kann er der gekaufte Platz sein, fuer den anderen ein Redesign seines vorherigen CCs. Dafuer haengt die Liste jetzt an der Mitgliedschaft statt am CC, und verschieben ordnet nur die eigene Liste. Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein CC-Guthaben dafuer einsetzt, wandelt es um: ein Platz weniger, ein bezahltes Redesign mehr. Damit entfaellt die Ausnahme, die sich bisher durch Slot-Pruefung und Kettenrechnung zog. Beim Start werden Buchungen einmalig an die Listen angeglichen.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
-- Ein CC-Guthaben, das fuer ein Redesign eingesetzt wird, wird umgewandelt
|
||||||
|
-- statt als Ausnahme gebucht. Der neue Wert steht allein in dieser Migration:
|
||||||
|
-- Postgres laesst einen frisch angelegten Enum-Wert in derselben Transaktion
|
||||||
|
-- noch nicht verwenden.
|
||||||
|
ALTER TYPE "CreditSource" ADD VALUE IF NOT EXISTS 'CONVERSION' BEFORE 'CONSUME';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Ob ein Eintrag ein Platz oder ein Redesign ist, entscheidet seine Stelle in
|
||||||
|
-- der Kette - nicht eine Markierung an der Buchung. Wer sein CC-Guthaben fuer
|
||||||
|
-- ein Redesign einsetzt, wandelt es um, statt eine Ausnahme zu buchen.
|
||||||
|
ALTER TABLE "CreditLedger" DROP COLUMN IF EXISTS "fundingPinned";
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- Ein Gruppen-CC wird fuer jeden Nutzer einzeln gerechnet: fuer den einen ist
|
||||||
|
-- er der gekaufte Platz, fuer den anderen ein Redesign seines vorherigen CCs.
|
||||||
|
-- Deshalb wandert die Liste vom CC an die Mitgliedschaft.
|
||||||
|
ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "lineageId" TEXT;
|
||||||
|
ALTER TABLE "CharacterMember" ADD COLUMN IF NOT EXISTS "chainPosition" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Wer den CC angelegt hat, bringt seine bisherige Kette mit.
|
||||||
|
UPDATE "CharacterMember" m
|
||||||
|
SET "lineageId" = c."lineageId", "chainPosition" = c."chainPosition"
|
||||||
|
FROM "Character" c
|
||||||
|
WHERE c.id = m."characterId" AND c."playerId" = m."playerId" AND m."lineageId" IS NULL;
|
||||||
|
|
||||||
|
-- Die uebrigen haengen an einem Eintrag, der schon zugeordnet ist: entweder am
|
||||||
|
-- CC, den sie dafuer aufgegeben haben, oder an einem frueheren Glied derselben
|
||||||
|
-- Kette, in der sie mitgezogen wurden. Beides kann mehrere Stufen tief gehen,
|
||||||
|
-- deshalb die Schleife.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
ueber_vorgaenger INTEGER;
|
||||||
|
ueber_kette INTEGER;
|
||||||
|
geaendert INTEGER := 1;
|
||||||
|
BEGIN
|
||||||
|
WHILE geaendert > 0 LOOP
|
||||||
|
UPDATE "CharacterMember" m
|
||||||
|
SET "lineageId" = v."lineageId", "chainPosition" = v."chainPosition" + 10
|
||||||
|
FROM "CharacterMember" v
|
||||||
|
WHERE v."characterId" = m."predecessorId"
|
||||||
|
AND v."playerId" = m."playerId"
|
||||||
|
AND v."lineageId" IS NOT NULL
|
||||||
|
AND m."lineageId" IS NULL;
|
||||||
|
GET DIAGNOSTICS ueber_vorgaenger = ROW_COUNT;
|
||||||
|
|
||||||
|
UPDATE "CharacterMember" m
|
||||||
|
SET "lineageId" = q."lineageId",
|
||||||
|
"chainPosition" = q."chainPosition" + (q."cc_pos" - q."von_pos")
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (offen.id)
|
||||||
|
offen.id AS mitglied_id,
|
||||||
|
v."lineageId",
|
||||||
|
v."chainPosition",
|
||||||
|
cm."chainPosition" AS cc_pos,
|
||||||
|
cv."chainPosition" AS von_pos
|
||||||
|
FROM "CharacterMember" offen
|
||||||
|
JOIN "Character" cm ON cm.id = offen."characterId"
|
||||||
|
JOIN "Character" cv
|
||||||
|
ON cv."lineageId" = cm."lineageId" AND cv."chainPosition" < cm."chainPosition"
|
||||||
|
JOIN "CharacterMember" v ON v."characterId" = cv.id AND v."playerId" = offen."playerId"
|
||||||
|
WHERE offen."lineageId" IS NULL AND v."lineageId" IS NOT NULL
|
||||||
|
ORDER BY offen.id, cv."chainPosition" DESC
|
||||||
|
) q
|
||||||
|
WHERE m.id = q.mitglied_id;
|
||||||
|
GET DIAGNOSTICS ueber_kette = ROW_COUNT;
|
||||||
|
|
||||||
|
geaendert := ueber_vorgaenger + ueber_kette;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Alles Uebrige beginnt eine eigene Liste: ein Platz, der fuer sich steht.
|
||||||
|
UPDATE "CharacterMember"
|
||||||
|
SET "lineageId" = gen_random_uuid()::text, "chainPosition" = 0
|
||||||
|
WHERE "lineageId" IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "CharacterMember" ALTER COLUMN "lineageId" SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "CharacterMember_lineageId_idx" ON "CharacterMember"("lineageId");
|
||||||
@@ -30,6 +30,9 @@ enum CreditSource {
|
|||||||
CC_GRANT
|
CC_GRANT
|
||||||
KULANZ
|
KULANZ
|
||||||
ADJUSTMENT
|
ADJUSTMENT
|
||||||
|
/// Ein CC-Guthaben, das in ein Redesign-Guthaben umgewandelt wurde.
|
||||||
|
/// Immer ein Paar: eine Zeile ab im CC-Topf, eine Zeile zu im Redesign-Topf.
|
||||||
|
CONVERSION
|
||||||
CONSUME
|
CONSUME
|
||||||
REVERSAL
|
REVERSAL
|
||||||
}
|
}
|
||||||
@@ -227,11 +230,6 @@ model CreditLedger {
|
|||||||
/// hier eingetippt hat.
|
/// hier eingetippt hat.
|
||||||
occurredAt DateTime @default(now())
|
occurredAt DateTime @default(now())
|
||||||
|
|
||||||
/// Von Hand so entschieden. Das Ausrichten der Buchungen an den Ketten
|
|
||||||
/// lässt solche Zeilen in Ruhe - sonst würde jede Ausnahme beim nächsten
|
|
||||||
/// Umsortieren wieder eingeebnet.
|
|
||||||
fundingPinned Boolean @default(false)
|
|
||||||
|
|
||||||
@@index([playerId])
|
@@index([playerId])
|
||||||
@@index([playerId, kind])
|
@@index([playerId, kind])
|
||||||
@@index([groupId])
|
@@index([groupId])
|
||||||
@@ -473,6 +471,15 @@ model CharacterMember {
|
|||||||
paidWith CreditKind?
|
paidWith CreditKind?
|
||||||
note String?
|
note String?
|
||||||
|
|
||||||
|
/// Die Liste dieses Spielers, in der der CC für ihn steht, und seine
|
||||||
|
/// Stelle darin.
|
||||||
|
///
|
||||||
|
/// Ein Gruppen-CC wird für jeden Nutzer einzeln gerechnet: Für den einen
|
||||||
|
/// kann er der gekaufte Platz sein, für den anderen ein Redesign seines
|
||||||
|
/// vorherigen CCs. Deshalb hängt die Kette am Nutzer, nicht am CC.
|
||||||
|
lineageId String @default(uuid())
|
||||||
|
chainPosition Int @default(0)
|
||||||
|
|
||||||
/// Der eigene CC, den der Spieler für diesen Platz aufgegeben hat. Nur
|
/// Der eigene CC, den der Spieler für diesen Platz aufgegeben hat. Nur
|
||||||
/// gesetzt, wenn der Beitritt über ein Redesign lief - dann gilt dieselbe
|
/// gesetzt, wenn der Beitritt über ein Redesign lief - dann gilt dieselbe
|
||||||
/// Regel wie sonst: ein Redesign ersetzt einen CC, es kommt keiner dazu.
|
/// Regel wie sonst: ein Redesign ersetzt einen CC, es kommt keiner dazu.
|
||||||
@@ -491,6 +498,7 @@ model CharacterMember {
|
|||||||
@@unique([characterId, playerId])
|
@@unique([characterId, playerId])
|
||||||
@@index([playerId])
|
@@index([playerId])
|
||||||
@@index([characterId])
|
@@index([characterId])
|
||||||
|
@@index([lineageId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wann ein wiederkehrender Lauf zuletzt lief. Der Zeitplan steht im Code,
|
/// Wann ein wiederkehrender Lauf zuletzt lief. Der Zeitplan steht im Code,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { buildApp } from './app.js';
|
|||||||
import { bootstrap } from './bootstrap.js';
|
import { bootstrap } from './bootstrap.js';
|
||||||
import { cleanupExpiredSessions } from './auth/session.js';
|
import { cleanupExpiredSessions } from './auth/session.js';
|
||||||
import { cleanupOldAuditEntries } from './services/audit.js';
|
import { cleanupOldAuditEntries } from './services/audit.js';
|
||||||
|
import { richteBuchungenAus } from './services/characters.js';
|
||||||
import { startSchedule } from './services/schedule.js';
|
import { startSchedule } from './services/schedule.js';
|
||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
import { env } from './env.js';
|
import { env } from './env.js';
|
||||||
@@ -23,6 +24,16 @@ async function main(): Promise<void> {
|
|||||||
warn: (msg) => app.log.warn(msg),
|
warn: (msg) => app.log.warn(msg),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Am Anfang einer Kette steht ein gekaufter Platz, darüber die Redesigns.
|
||||||
|
// Der Lauf gleicht Buchungen an, die dem noch widersprechen - und tut
|
||||||
|
// nichts, wenn alles passt.
|
||||||
|
try {
|
||||||
|
const angeglichen = await richteBuchungenAus();
|
||||||
|
if (angeglichen > 0) app.log.info(`${angeglichen} Buchungen an die Ketten angeglichen`);
|
||||||
|
} catch (error) {
|
||||||
|
app.log.warn({ err: error }, 'Angleichen der Buchungen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = setInterval(() => {
|
const cleanup = setInterval(() => {
|
||||||
cleanupExpiredSessions()
|
cleanupExpiredSessions()
|
||||||
.then((count) => {
|
.then((count) => {
|
||||||
|
|||||||
@@ -245,10 +245,18 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return moveInChain(id, body, contextOf(request));
|
return moveInChain(id, body, contextOf(request));
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Löst einen CC aus seiner Kette und stellt ihn allein. */
|
/** Löst einen CC aus einer Liste und stellt ihn allein. */
|
||||||
app.post('/api/characters/:id/detach', { preHandler: requireEditor }, async (request) => {
|
app.post('/api/characters/:id/detach', { preHandler: requireEditor }, async (request) => {
|
||||||
const { id } = parseWith(idParam, request.params);
|
const { id } = parseWith(idParam, request.params);
|
||||||
return detachFromChain(id, contextOf(request));
|
const body = parseWith(
|
||||||
|
z.object({
|
||||||
|
/// Wessen Liste gerade geordnet wird - bei einem Gruppen-CC ist das
|
||||||
|
/// nicht zwingend sein Ersteller.
|
||||||
|
forPlayerId: z.number().int().positive().optional(),
|
||||||
|
}),
|
||||||
|
request.body ?? {},
|
||||||
|
);
|
||||||
|
return detachFromChain(id, contextOf(request), body.forPlayerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Gruppen-CCs ---
|
// --- Gruppen-CCs ---
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ export async function exportAll(): Promise<string> {
|
|||||||
cc_name: m.character.name,
|
cc_name: m.character.name,
|
||||||
bezeichnung: kennung(m.character.playerId).spieler,
|
bezeichnung: kennung(m.character.playerId).spieler,
|
||||||
bezahlt_mit: m.paidWith ?? '',
|
bezahlt_mit: m.paidWith ?? '',
|
||||||
|
// Die Liste hängt am Nutzer: derselbe Gruppen-CC steht bei jedem an
|
||||||
|
// einer anderen Stelle.
|
||||||
|
kette: m.lineageId,
|
||||||
|
kette_position: String(m.chainPosition),
|
||||||
zeitpunkt: datum(m.joinedAt),
|
zeitpunkt: datum(m.joinedAt),
|
||||||
cc_grund: m.inactiveReason ?? '',
|
cc_grund: m.inactiveReason ?? '',
|
||||||
cc_notiz: m.inactiveNote ?? '',
|
cc_notiz: m.inactiveNote ?? '',
|
||||||
@@ -380,7 +384,13 @@ async function spieleZeileEin(
|
|||||||
// Der Besitzer ist immer auch Mitglied; eigene MITGLIED-Zeilen
|
// Der Besitzer ist immer auch Mitglied; eigene MITGLIED-Zeilen
|
||||||
// ergänzen die übrigen.
|
// ergänzen die übrigen.
|
||||||
await prisma.characterMember.create({
|
await prisma.characterMember.create({
|
||||||
data: { characterId: erstellt.id, playerId, createdById: ctx.actorId },
|
data: {
|
||||||
|
characterId: erstellt.id,
|
||||||
|
playerId,
|
||||||
|
lineageId: erstellt.lineageId,
|
||||||
|
chainPosition: erstellt.chainPosition,
|
||||||
|
createdById: ctx.actorId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -413,6 +423,15 @@ async function spieleZeileEin(
|
|||||||
inactiveReason: (zeile.cc_grund?.trim() || null) as never,
|
inactiveReason: (zeile.cc_grund?.trim() || null) as never,
|
||||||
inactiveNote: zeile.cc_notiz?.trim() || null,
|
inactiveNote: zeile.cc_notiz?.trim() || null,
|
||||||
leftAt: zeile.wert ? new Date(zeile.wert) : null,
|
leftAt: zeile.wert ? new Date(zeile.wert) : null,
|
||||||
|
// Aus einer Sicherung von vor der Umstellung kommt keine Liste
|
||||||
|
// mit; dann beginnt der Eintrag eine eigene, und der Abgleich
|
||||||
|
// beim Start ordnet den Rest.
|
||||||
|
...(zeile.kette?.trim()
|
||||||
|
? {
|
||||||
|
lineageId: zeile.kette.trim(),
|
||||||
|
chainPosition: Number(zeile.kette_position) || 0,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(zeile.zeitpunkt ? { joinedAt: new Date(zeile.zeitpunkt) } : {}),
|
...(zeile.zeitpunkt ? { joinedAt: new Date(zeile.zeitpunkt) } : {}),
|
||||||
note: zeile.bemerkung?.trim() || null,
|
note: zeile.bemerkung?.trim() || null,
|
||||||
createdById: ctx.actorId,
|
createdById: ctx.actorId,
|
||||||
|
|||||||
+533
-248
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,7 @@ export async function getBalances(playerId: number): Promise<Balances> {
|
|||||||
kind: true,
|
kind: true,
|
||||||
amount: true,
|
amount: true,
|
||||||
source: true,
|
source: true,
|
||||||
reversalOf: { select: { source: true } },
|
reversalOf: { select: { source: true, amount: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,9 +67,16 @@ export async function getBalances(playerId: number): Promise<Balances> {
|
|||||||
for (const eintrag of eintraege) {
|
for (const eintrag of eintraege) {
|
||||||
sums[eintrag.kind] = (sums[eintrag.kind] ?? 0) + eintrag.amount;
|
sums[eintrag.kind] = (sums[eintrag.kind] ?? 0) + eintrag.amount;
|
||||||
|
|
||||||
const zweck =
|
// Ein Storno zählt dorthin, wohin die stornierte Zeile zählte.
|
||||||
eintrag.source === CreditSource.REVERSAL ? eintrag.reversalOf?.source : eintrag.source;
|
const bezug =
|
||||||
if (zweck !== CreditSource.CONSUME) {
|
eintrag.source === CreditSource.REVERSAL ? (eintrag.reversalOf ?? eintrag) : eintrag;
|
||||||
|
// Eine Umwandlung ist auf der einen Seite Verbrauch, auf der anderen
|
||||||
|
// Zugang: wer ein CC-Guthaben in ein Redesign tauscht, hat das CC-Paket
|
||||||
|
// gekauft und aufgebraucht - "0 von 3" statt "0 von 2".
|
||||||
|
const istVerbrauch =
|
||||||
|
bezug.source === CreditSource.CONSUME ||
|
||||||
|
(bezug.source === CreditSource.CONVERSION && bezug.amount < 0);
|
||||||
|
if (!istVerbrauch) {
|
||||||
granted[eintrag.kind] = (granted[eintrag.kind] ?? 0) + eintrag.amount;
|
granted[eintrag.kind] = (granted[eintrag.kind] ?? 0) + eintrag.amount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +188,50 @@ export async function bookCcPackage(
|
|||||||
return { groupId, balances: await getBalances(playerId) };
|
return { groupId, balances: await getBalances(playerId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt ein CC-Guthaben in ein bezahltes Redesign um.
|
||||||
|
*
|
||||||
|
* Ein Redesign wird nie aus dem CC-Topf bezahlt. Wer sein CC-Guthaben dafür
|
||||||
|
* einsetzen will, tauscht es vorher: ein Platz weniger, ein Redesign mehr.
|
||||||
|
* Danach ist der Eintrag ein Redesign wie jedes andere - keine Ausnahme, die
|
||||||
|
* sich durch Slot-Prüfung, Kettenrechnung und Anzeige ziehen müsste.
|
||||||
|
*
|
||||||
|
* Läuft immer in der Transaktion des Vorgangs mit, der die Umwandlung
|
||||||
|
* auslöst: beides gilt zusammen oder gar nicht.
|
||||||
|
*/
|
||||||
|
export async function wandleCcInRedesign(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
params: {
|
||||||
|
playerId: number;
|
||||||
|
/// Der Eintrag, für den das Guthaben eingesetzt wird. Daran hängt die
|
||||||
|
/// Umwandlung, damit sie sich zurücknehmen lässt, wenn der Eintrag
|
||||||
|
/// später doch einen eigenen Platz bekommt.
|
||||||
|
characterId: number;
|
||||||
|
actorId: number | null;
|
||||||
|
note: string;
|
||||||
|
/// Wann der Vorgang war, zu dem die Umwandlung gehört.
|
||||||
|
occurredAt?: Date | null;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const groupId = randomUUID();
|
||||||
|
const gemeinsam = {
|
||||||
|
playerId: params.playerId,
|
||||||
|
characterId: params.characterId,
|
||||||
|
source: CreditSource.CONVERSION,
|
||||||
|
groupId,
|
||||||
|
note: params.note,
|
||||||
|
createdById: params.actorId,
|
||||||
|
...(params.occurredAt ? { occurredAt: params.occurredAt } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: { ...gemeinsam, kind: CreditKind.CC, amount: -1 },
|
||||||
|
});
|
||||||
|
await tx.creditLedger.create({
|
||||||
|
data: { ...gemeinsam, kind: CreditKind.REDESIGN_PAID, amount: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vergibt einen CC-Platz von Hand, ohne das Redesign aus einem Paket.
|
* Vergibt einen CC-Platz von Hand, ohne das Redesign aus einem Paket.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -760,7 +760,7 @@ export async function classifyThread(
|
|||||||
if (target.inactiveReason === 'REWORKED') {
|
if (target.inactiveReason === 'REWORKED') {
|
||||||
const result = await reactivatePaid(
|
const result = await reactivatePaid(
|
||||||
input.characterId,
|
input.characterId,
|
||||||
input.useKind ?? 'REDESIGN_KULANZ',
|
input.useKind ?? 'REDESIGN_PAID',
|
||||||
ctx,
|
ctx,
|
||||||
note,
|
note,
|
||||||
);
|
);
|
||||||
@@ -793,8 +793,8 @@ export async function classifyThread(
|
|||||||
if (input.predecessorId === undefined) {
|
if (input.predecessorId === undefined) {
|
||||||
throw badRequest('NO_PREDECESSOR', 'Bitte angeben, welcher CC redesignt wurde');
|
throw badRequest('NO_PREDECESSOR', 'Bitte angeben, welcher CC redesignt wurde');
|
||||||
}
|
}
|
||||||
// Ein Redesign kann nicht aus dem CC-Topf bezahlt werden.
|
// Kulanz wird bewusst vergeben. Ohne Angabe gilt der Regelfall.
|
||||||
const redesignKind = input.useKind === 'REDESIGN_PAID' ? 'REDESIGN_PAID' : 'REDESIGN_KULANZ';
|
const redesignKind = input.useKind === 'REDESIGN_KULANZ' ? 'REDESIGN_KULANZ' : 'REDESIGN_PAID';
|
||||||
const result = await redesignCharacter(
|
const result = await redesignCharacter(
|
||||||
input.predecessorId,
|
input.predecessorId,
|
||||||
{ name, loreText: input.loreText, useKind: redesignKind, note },
|
{ name, loreText: input.loreText, useKind: redesignKind, note },
|
||||||
|
|||||||
@@ -769,3 +769,106 @@ describe('Der eigene CC bleibt der eigene', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Ein Gruppen-CC zählt für jeden Nutzer einzeln', () => {
|
||||||
|
/// Womit ein bestimmter Spieler diesen CC bezahlt hat.
|
||||||
|
async function bezahltMit(characterId: number, playerId: number): Promise<string | undefined> {
|
||||||
|
const buchung = await db.prisma.creditLedger.findFirst({
|
||||||
|
where: { characterId, playerId, source: 'CONSUME', reversedBy: null },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
});
|
||||||
|
return buchung?.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die Liste dieses Spielers, von unten nach oben.
|
||||||
|
async function liste(playerId: number): Promise<string[]> {
|
||||||
|
const eigene = await db.prisma.characterMember.findMany({
|
||||||
|
where: { playerId },
|
||||||
|
orderBy: [{ lineageId: 'asc' }, { chainPosition: 'asc' }],
|
||||||
|
select: { lineageId: true, character: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
return eigene.map((e) => `${e.lineageId.slice(0, 4)}:${e.character.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('ist für den einen ein Platz und für den anderen ein Redesign', async () => {
|
||||||
|
const wirt = await spielerMitPaket('Zaehlwirt');
|
||||||
|
const gast = await spielerMitPaket('Zaehlgast');
|
||||||
|
const gruppe = await characters.createCharacter(wirt, { name: 'Gemeinsames Werk' }, ctx);
|
||||||
|
const eigener = await characters.createCharacter(gast, { name: 'Sein Erster' }, ctx);
|
||||||
|
|
||||||
|
await credits.bookRedesignPackage(gast, ctx);
|
||||||
|
await characters.addMember(
|
||||||
|
gruppe.character.id,
|
||||||
|
gast,
|
||||||
|
{ useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(await bezahltMit(gruppe.character.id, wirt), 'CC', 'für den Wirt sein Platz');
|
||||||
|
assert.equal(
|
||||||
|
await bezahltMit(gruppe.character.id, gast),
|
||||||
|
'REDESIGN_PAID',
|
||||||
|
'für den Gast das Redesign seines ersten CCs',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await characters.richteBuchungenAus(),
|
||||||
|
0,
|
||||||
|
'beide Sichten sind in sich stimmig und bleiben unangetastet',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Die Rechnung des Gastes: eine Liste mit zwei Einträgen.
|
||||||
|
const stand = await credits.getBalances(gast);
|
||||||
|
assert.equal(stand.CC, 0, 'ein Platz, eine Liste');
|
||||||
|
assert.equal(stand.REDESIGN_PAID, 1, 'von zwei Redesigns eines verbraucht');
|
||||||
|
assert.deepEqual(
|
||||||
|
(await liste(gast)).map((e) => e.split(':')[1]),
|
||||||
|
['Sein Erster', 'Gemeinsames Werk'],
|
||||||
|
'in seiner Liste steht der Gruppen-CC über seinem ersten',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt jeden für sich verschieben, ohne den anderen anzufassen', async () => {
|
||||||
|
const wirt = await spielerMitPaket('Schiebewirt', 2);
|
||||||
|
const gast = await spielerMitPaket('Schiebegast', 2);
|
||||||
|
const gruppe = await characters.createCharacter(wirt, { name: 'Die Bude' }, ctx);
|
||||||
|
const eigener = await characters.createCharacter(gast, { name: 'Gasts Erster' }, ctx);
|
||||||
|
|
||||||
|
await characters.addMember(gruppe.character.id, gast, { useKind: 'CC' }, ctx);
|
||||||
|
assert.equal(await bezahltMit(gruppe.character.id, gast), 'CC', 'erst ein eigener Platz');
|
||||||
|
|
||||||
|
// Der Gast reiht den Gruppen-CC in seine Liste ein: für ihn wird daraus
|
||||||
|
// ein Redesign seines ersten CCs.
|
||||||
|
await credits.bookRedesignPackage(gast, ctx);
|
||||||
|
await characters.moveInChain(
|
||||||
|
gruppe.character.id,
|
||||||
|
{
|
||||||
|
lineageId: (
|
||||||
|
await db.prisma.characterMember.findFirstOrThrow({
|
||||||
|
where: { playerId: gast, characterId: eigener.character.id },
|
||||||
|
})
|
||||||
|
).lineageId,
|
||||||
|
afterId: eigener.character.id,
|
||||||
|
forPlayerId: gast,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await bezahltMit(gruppe.character.id, gast),
|
||||||
|
'REDESIGN_PAID',
|
||||||
|
'in seiner Liste ist es jetzt ein Redesign',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await bezahltMit(gruppe.character.id, wirt),
|
||||||
|
'CC',
|
||||||
|
'beim Wirt ändert sich dadurch nichts',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Und der CC selbst bleibt aktiv - der Wirt spielt ihn weiter.
|
||||||
|
const danach = await db.prisma.character.findUniqueOrThrow({
|
||||||
|
where: { id: gruppe.character.id },
|
||||||
|
});
|
||||||
|
assert.equal(danach.status, 'ACTIVE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+291
-32
@@ -1,5 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
import { after, before, describe, it } from 'node:test';
|
import { after, before, describe, it } from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { startTestDatabase, type TestDatabase } from './harness.js';
|
import { startTestDatabase, type TestDatabase } from './harness.js';
|
||||||
|
|
||||||
type Characters = typeof import('../src/services/characters.js');
|
type Characters = typeof import('../src/services/characters.js');
|
||||||
@@ -43,13 +46,23 @@ async function spielerMitPaketen(name: string, pakete = 1) {
|
|||||||
* Eine Kette, wie sie angezeigt würde. Massgeblich ist die Linie, nicht der
|
* Eine Kette, wie sie angezeigt würde. Massgeblich ist die Linie, nicht der
|
||||||
* Besitzer: ein Gruppen-CC gehört einem anderen und steht trotzdem darin.
|
* Besitzer: ein Gruppen-CC gehört einem anderen und steht trotzdem darin.
|
||||||
*/
|
*/
|
||||||
async function kette(_playerId: number, lineageId: string) {
|
async function kette(playerId: number, lineageId: string) {
|
||||||
const glieder = await db.prisma.character.findMany({
|
const glieder = await db.prisma.characterMember.findMany({
|
||||||
where: { lineageId },
|
where: { playerId, lineageId },
|
||||||
orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }],
|
orderBy: [{ chainPosition: 'asc' }, { id: 'asc' }],
|
||||||
select: { id: true, name: true, predecessorId: true, chainPosition: true },
|
select: {
|
||||||
|
characterId: true,
|
||||||
|
chainPosition: true,
|
||||||
|
predecessorId: true,
|
||||||
|
character: { select: { name: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return glieder;
|
return glieder.map((glied) => ({
|
||||||
|
id: glied.characterId,
|
||||||
|
name: glied.character.name,
|
||||||
|
predecessorId: glied.predecessorId,
|
||||||
|
chainPosition: glied.chainPosition,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prüft, dass Positionen und Vorgänger-Verweise dasselbe sagen. */
|
/** Prüft, dass Positionen und Vorgänger-Verweise dasselbe sagen. */
|
||||||
@@ -493,22 +506,22 @@ describe('Redesign nachträglich umbuchen', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stellt einen gekauften CC auf Redesign um', async () => {
|
it('macht aus einem Kettenanfang kein Redesign', async () => {
|
||||||
// Genau der Korrekturfall: ein Eintrag war gar nicht der Anfang einer
|
// Ob ein Eintrag ein Redesign ist, entscheidet seine Stelle in der Kette.
|
||||||
// Kette, sondern ging aus einem anderen hervor.
|
// Sonst ginge die Rechnung "eine Kette = ein CC-Paket" nicht mehr auf.
|
||||||
const spieler = await spielerMitPaketen('Umsteller', 1);
|
const spieler = await spielerMitPaketen('Umsteller', 1);
|
||||||
const eins = await characters.createCharacter(spieler, { name: 'Direkt aus dem Paket' }, ctx);
|
const eins = await characters.createCharacter(spieler, { name: 'Direkt aus dem Paket' }, ctx);
|
||||||
await credits.bookRedesignPackage(spieler, ctx);
|
await credits.bookRedesignPackage(spieler, ctx);
|
||||||
|
|
||||||
const danach = await characters.changeFunding(eins.character.id, 'REDESIGN_PAID', ctx);
|
|
||||||
assert.equal(danach.balances.CC, 1, 'das CC-Guthaben kommt zurück');
|
|
||||||
assert.equal(danach.balances.REDESIGN_PAID, 1, 'dafür geht ein Redesign weg');
|
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => characters.changeFunding(eins.character.id, 'REDESIGN_PAID', ctx),
|
() => characters.changeFunding(eins.character.id, 'REDESIGN_PAID', ctx),
|
||||||
(error: Error & { code?: string }) => error.code === 'SAME_KIND',
|
(error: Error & { code?: string }) => error.code === 'CHAIN_START',
|
||||||
'ein CC aus dem Paket ist schon als CC gebucht',
|
'dafür wird er eingereiht, nicht umgebucht',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const stand = await credits.getBalances(spieler);
|
||||||
|
assert.equal(stand.CC, 0, 'der Platz bleibt gebucht');
|
||||||
|
assert.equal(stand.REDESIGN_PAID, 2, 'und das Redesign unangetastet');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1045,8 +1058,16 @@ describe('Kulanz gegen ein Minus verrechnen', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Ein Redesign ausnahmsweise aus dem CC-Topf', () => {
|
describe('Ein CC-Guthaben für ein Redesign einsetzen', () => {
|
||||||
it('nimmt ein CC-Guthaben an', async () => {
|
async function verbrauchtAls(characterId: number): Promise<string | undefined> {
|
||||||
|
const buchung = await db.prisma.creditLedger.findFirst({
|
||||||
|
where: { characterId, source: 'CONSUME', reversedBy: null },
|
||||||
|
orderBy: { id: 'desc' },
|
||||||
|
});
|
||||||
|
return buchung?.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('wandelt es um, statt eine Ausnahme zu buchen', async () => {
|
||||||
const spieler = await spielerMitPaketen('Ausnahmefall', 2);
|
const spieler = await spielerMitPaketen('Ausnahmefall', 2);
|
||||||
const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx);
|
const eins = await characters.createCharacter(spieler, { name: 'Basis' }, ctx);
|
||||||
|
|
||||||
@@ -1057,13 +1078,26 @@ describe('Ein Redesign ausnahmsweise aus dem CC-Topf', () => {
|
|||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(zwei.balances.CC, vorher.CC - 1, 'bezahlt aus dem CC-Topf');
|
assert.equal(zwei.balances.CC, vorher.CC - 1, 'der Platz ist weg');
|
||||||
assert.equal(zwei.balances.REDESIGN_PAID, vorher.REDESIGN_PAID, 'die Redesigns bleiben');
|
assert.equal(
|
||||||
|
zwei.balances.REDESIGN_PAID,
|
||||||
|
vorher.REDESIGN_PAID,
|
||||||
|
'das eingetauschte Redesign wird sofort wieder verbraucht',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await verbrauchtAls(zwei.character.id),
|
||||||
|
'REDESIGN_PAID',
|
||||||
|
'der Eintrag selbst ist ein Redesign wie jedes andere',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
zwei.balances.granted.REDESIGN_PAID,
|
||||||
|
vorher.granted.REDESIGN_PAID + 1,
|
||||||
|
'und das umgewandelte Guthaben ist als erhalten ausgewiesen',
|
||||||
|
);
|
||||||
|
assert.equal(zwei.balances.granted.CC, vorher.granted.CC, 'die Käufe bleiben, wie sie waren');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lässt sich vom Ausrichten an den Ketten nicht überschreiben', async () => {
|
it('bleibt beim Ausrichten an den Ketten stehen', async () => {
|
||||||
// Ohne Merker würde die Automatik beim nächsten Umsortieren daraus
|
|
||||||
// wieder ein gewöhnliches Redesign machen.
|
|
||||||
const spieler = await spielerMitPaketen('Bleibt so', 2);
|
const spieler = await spielerMitPaketen('Bleibt so', 2);
|
||||||
const eins = await characters.createCharacter(spieler, { name: 'Erstes' }, ctx);
|
const eins = await characters.createCharacter(spieler, { name: 'Erstes' }, ctx);
|
||||||
const zwei = await characters.redesignCharacter(
|
const zwei = await characters.redesignCharacter(
|
||||||
@@ -1072,16 +1106,32 @@ describe('Ein Redesign ausnahmsweise aus dem CC-Topf', () => {
|
|||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
await characters.rebuildFunding(spieler, ctx);
|
const vorher = await credits.getBalances(spieler);
|
||||||
|
const lauf = await characters.rebuildFunding(spieler, ctx);
|
||||||
|
|
||||||
const buchung = await db.prisma.creditLedger.findFirst({
|
assert.equal(lauf.umgebucht, 0, 'es passt bereits alles');
|
||||||
where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null },
|
assert.equal(await verbrauchtAls(zwei.character.id), 'REDESIGN_PAID');
|
||||||
orderBy: { id: 'desc' },
|
assert.deepEqual(lauf.balances, vorher, 'und am Guthaben ändert sich nichts');
|
||||||
});
|
|
||||||
assert.equal(buchung?.kind, 'CC', 'die bewusste Entscheidung bleibt stehen');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merkt sich auch eine von Hand geänderte Bezahlart', async () => {
|
it('gibt den Tausch zurück, wenn der Eintrag wieder allein steht', async () => {
|
||||||
|
const spieler = await spielerMitPaketen('Rueckzieher', 2);
|
||||||
|
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
|
||||||
|
const zwei = await characters.redesignCharacter(
|
||||||
|
eins.character.id,
|
||||||
|
{ name: 'B', useKind: 'CC' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
await characters.detachFromChain(zwei.character.id, ctx);
|
||||||
|
|
||||||
|
assert.equal(await verbrauchtAls(zwei.character.id), 'CC', 'allein steht er für sich');
|
||||||
|
const stand = await credits.getBalances(spieler);
|
||||||
|
assert.equal(stand.CC, 0, 'beide Plätze sind belegt');
|
||||||
|
assert.equal(stand.REDESIGN_PAID, 2, 'die Redesigns sind wieder frei');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt sich auch nachträglich einsetzen', async () => {
|
||||||
const spieler = await spielerMitPaketen('Von Hand', 2);
|
const spieler = await spielerMitPaketen('Von Hand', 2);
|
||||||
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
|
const eins = await characters.createCharacter(spieler, { name: 'A' }, ctx);
|
||||||
const zwei = await characters.redesignCharacter(
|
const zwei = await characters.redesignCharacter(
|
||||||
@@ -1090,13 +1140,222 @@ describe('Ein Redesign ausnahmsweise aus dem CC-Topf', () => {
|
|||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
await characters.changeFunding(zwei.character.id, 'CC', ctx);
|
const danach = await characters.changeFunding(zwei.character.id, 'CC', ctx);
|
||||||
await characters.rebuildFunding(spieler, ctx);
|
|
||||||
|
|
||||||
|
assert.equal(await verbrauchtAls(zwei.character.id), 'REDESIGN_PAID');
|
||||||
|
assert.equal(danach.balances.CC, 0, 'der Platz ist eingetauscht');
|
||||||
|
assert.equal(danach.balances.REDESIGN_PAID, 2, 'das Redesign dafür wieder frei');
|
||||||
|
|
||||||
|
// Und zurück: der Tausch wird rückgängig gemacht.
|
||||||
|
const zurueck = await characters.changeFunding(zwei.character.id, 'REDESIGN_PAID', ctx);
|
||||||
|
assert.equal(zurueck.balances.CC, 1, 'der Platz kommt zurück');
|
||||||
|
assert.equal(zurueck.balances.REDESIGN_PAID, 1, 'und das Redesign wird wieder verbraucht');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Buchungen an die Ketten angleichen', () => {
|
||||||
|
it('bucht den Anfang einer Kette auf einen Platz', async () => {
|
||||||
|
// Der Fall aus dem Bestand: drei Ketten, aber nur zwei gebuchte Plätze -
|
||||||
|
// bei einer war der unterste Eintrag als Redesign gebucht.
|
||||||
|
const spieler = await spielerMitPaketen('Schieflage', 2);
|
||||||
|
const eins = await characters.createCharacter(spieler, { name: 'Kettenfuss' }, ctx);
|
||||||
|
await db.prisma.creditLedger.updateMany({
|
||||||
|
where: { characterId: eins.character.id, source: 'CONSUME' },
|
||||||
|
data: { kind: 'REDESIGN_PAID' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const abweichend = await db.prisma.creditLedger.findMany({
|
||||||
|
where: { source: 'CONSUME', reversedBy: null },
|
||||||
|
select: { kind: true, playerId: true, character: { select: { name: true, playerId: true, predecessorId: true } } },
|
||||||
|
});
|
||||||
|
for (const e of abweichend) {
|
||||||
|
const cc = e.character;
|
||||||
|
if (!cc) continue;
|
||||||
|
const soll = cc.playerId === e.playerId ? cc.predecessorId === null : null;
|
||||||
|
if (soll !== null && (e.kind === 'CC') !== soll) {
|
||||||
|
console.log('ABWEICHUNG', cc.name, e.kind, 'predecessor', cc.predecessorId, 'spieler', e.playerId, 'besitzer', cc.playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const angeglichen = await characters.richteBuchungenAus();
|
||||||
|
assert.equal(angeglichen, 1, 'genau diese eine Buchung passte nicht');
|
||||||
|
|
||||||
const buchung = await db.prisma.creditLedger.findFirst({
|
const buchung = await db.prisma.creditLedger.findFirst({
|
||||||
where: { characterId: zwei.character.id, source: 'CONSUME', reversedBy: null },
|
where: { characterId: eins.character.id, source: 'CONSUME', reversedBy: null },
|
||||||
orderBy: { id: 'desc' },
|
orderBy: { id: 'desc' },
|
||||||
|
});
|
||||||
|
assert.equal(buchung?.kind, 'CC', 'wer unten steht, hat einen Platz gekauft');
|
||||||
|
|
||||||
|
const stand = await credits.getBalances(spieler);
|
||||||
|
assert.equal(stand.CC, 1, 'ein Platz belegt, einer frei');
|
||||||
|
assert.equal(stand.REDESIGN_PAID, 2, 'das Redesign ist wieder frei');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt den zweiten Lauf alles, wie es ist', async () => {
|
||||||
|
const spieler = await spielerMitPaketen('Ruhig', 1);
|
||||||
|
const eins = await characters.createCharacter(spieler, { name: 'Unten' }, ctx);
|
||||||
|
await characters.redesignCharacter(
|
||||||
|
eins.character.id,
|
||||||
|
{ name: 'Oben', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(await characters.richteBuchungenAus(), 0, 'es passt bereits alles');
|
||||||
|
const vorher = await db.prisma.creditLedger.count({ where: { playerId: spieler } });
|
||||||
|
await characters.richteBuchungenAus();
|
||||||
|
assert.equal(
|
||||||
|
await db.prisma.creditLedger.count({ where: { playerId: spieler } }),
|
||||||
|
vorher,
|
||||||
|
'und es entstehen keine Zeilen aus dem Nichts',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erkennt den aufgegebenen CC eines Mitglieds', async () => {
|
||||||
|
// Für ein Mitglied steht der Vorgänger an der Mitgliedschaft, nicht am
|
||||||
|
// Gruppen-CC. Sonst würde sein Redesign hier zu einem Platz.
|
||||||
|
const gruender = await spielerMitPaketen('Wirt', 1);
|
||||||
|
const gast = await spielerMitPaketen('Gast', 1);
|
||||||
|
const gruppe = await characters.createCharacter(gruender, { name: 'Stammtisch' }, ctx);
|
||||||
|
const eigener = await characters.createCharacter(gast, { name: 'Eigener' }, ctx);
|
||||||
|
|
||||||
|
await credits.bookRedesignPackage(gast, ctx);
|
||||||
|
await characters.addMember(
|
||||||
|
gruppe.character.id,
|
||||||
|
gast,
|
||||||
|
{ useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const vorher = await credits.getBalances(gast);
|
||||||
|
assert.equal(await characters.richteBuchungenAus(), 0, 'sein Beitritt war ein Redesign');
|
||||||
|
assert.deepEqual(await credits.getBalances(gast), vorher);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Die Umstellung auf Listen je Nutzer', () => {
|
||||||
|
const MIGRATION = join(
|
||||||
|
dirname(fileURLToPath(import.meta.url)),
|
||||||
|
'..',
|
||||||
|
'prisma',
|
||||||
|
'migrations',
|
||||||
|
'20250117000000_kette_je_nutzer',
|
||||||
|
'migration.sql',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Migration noch einmal anwenden.
|
||||||
|
*
|
||||||
|
* Jeder Schritt greift nur, wo noch keine Liste steht - so lässt sich der
|
||||||
|
* Weg nachstellen, den der Bestand beim Aktualisieren nimmt. Zerlegt wird
|
||||||
|
* an Semikolons, wobei ein DO-Block am Stück bleibt.
|
||||||
|
*/
|
||||||
|
async function migrationAusfuehren(): Promise<void> {
|
||||||
|
const sql = readFileSync(MIGRATION, 'utf8');
|
||||||
|
let puffer = '';
|
||||||
|
for (const teil of sql.split(';')) {
|
||||||
|
puffer += `${teil};`;
|
||||||
|
if ((puffer.match(/\$\$/g) ?? []).length % 2 !== 0) continue;
|
||||||
|
if (puffer.replace(/;/g, '').trim()) await db.prisma.$executeRawUnsafe(puffer);
|
||||||
|
puffer = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('ordnet einen Bestand ohne Listen wieder richtig zu', async () => {
|
||||||
|
const wirt = await spielerMitPaketen('Altbestand-Wirt', 1);
|
||||||
|
const gast = await spielerMitPaketen('Altbestand-Gast', 2);
|
||||||
|
|
||||||
|
// Eine Kette des Wirts, in der der Gast ab dem zweiten Glied mitzieht.
|
||||||
|
const eins = await characters.createCharacter(wirt, { name: 'Alt-Eins' }, ctx);
|
||||||
|
const zwei = await characters.redesignCharacter(
|
||||||
|
eins.character.id,
|
||||||
|
{ name: 'Alt-Zwei', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const drei = await characters.redesignCharacter(
|
||||||
|
zwei.character.id,
|
||||||
|
{ name: 'Alt-Drei', useKind: 'REDESIGN_PAID' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const eigener = await characters.createCharacter(gast, { name: 'Gasts Alter' }, ctx);
|
||||||
|
await credits.bookRedesignPackage(gast, ctx);
|
||||||
|
await characters.addMemberToChain(
|
||||||
|
zwei.character.id,
|
||||||
|
gast,
|
||||||
|
{ useKind: 'REDESIGN_PAID', predecessorId: eigener.character.id },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const vorher = new Map(
|
||||||
|
(
|
||||||
|
await db.prisma.characterMember.findMany({
|
||||||
|
select: { id: true, lineageId: true, chainPosition: true },
|
||||||
|
})
|
||||||
|
).map((m) => [m.id, m]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zurück auf den Stand vor der Umstellung.
|
||||||
|
await db.prisma.$executeRawUnsafe(
|
||||||
|
'ALTER TABLE "CharacterMember" ALTER COLUMN "lineageId" DROP NOT NULL',
|
||||||
|
);
|
||||||
|
await db.prisma.$executeRawUnsafe(
|
||||||
|
'UPDATE "CharacterMember" SET "lineageId" = NULL, "chainPosition" = 0',
|
||||||
|
);
|
||||||
|
|
||||||
|
await migrationAusfuehren();
|
||||||
|
|
||||||
|
const danach = await db.prisma.characterMember.findMany({
|
||||||
|
where: { playerId: gast },
|
||||||
|
orderBy: { chainPosition: 'asc' },
|
||||||
|
select: { characterId: true, lineageId: true, character: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
danach.map((m) => m.character.name),
|
||||||
|
['Gasts Alter', 'Alt-Zwei', 'Alt-Drei'],
|
||||||
|
'der Gast führt seinen alten CC und die Glieder, ab denen er dabei war',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
new Set(danach.map((m) => m.lineageId)).size,
|
||||||
|
1,
|
||||||
|
'und zwar in einer einzigen Liste',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Für den Wirt steht die Kette wieder so, wie sie war.
|
||||||
|
const wirtDanach = await db.prisma.characterMember.findMany({
|
||||||
|
where: { playerId: wirt },
|
||||||
|
orderBy: { chainPosition: 'asc' },
|
||||||
|
select: { id: true, lineageId: true, chainPosition: true },
|
||||||
});
|
});
|
||||||
assert.equal(buchung?.kind, 'CC');
|
for (const m of wirtDanach) {
|
||||||
|
assert.deepEqual(
|
||||||
|
{ lineageId: m.lineageId, chainPosition: m.chainPosition },
|
||||||
|
{
|
||||||
|
lineageId: vorher.get(m.id)?.lineageId,
|
||||||
|
chainPosition: vorher.get(m.id)?.chainPosition,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await characters.richteBuchungenAus(),
|
||||||
|
0,
|
||||||
|
'und die Buchungen passen danach zu den Listen',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Der Gast zahlt für seinen Einstieg ein Redesign, nicht einen Platz.
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await db.prisma.creditLedger.findFirst({
|
||||||
|
where: {
|
||||||
|
characterId: zwei.character.id,
|
||||||
|
playerId: gast,
|
||||||
|
source: 'CONSUME',
|
||||||
|
reversedBy: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)?.kind,
|
||||||
|
'REDESIGN_PAID',
|
||||||
|
);
|
||||||
|
assert.ok(drei.character.id > 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -219,7 +219,10 @@ export default function ChainView({
|
|||||||
if (!ja) return;
|
if (!ja) return;
|
||||||
setMeldung(null);
|
setMeldung(null);
|
||||||
try {
|
try {
|
||||||
await api(`/api/characters/${character.id}/detach`, { method: 'POST', body: {} });
|
await api(`/api/characters/${character.id}/detach`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { forPlayerId: playerId },
|
||||||
|
});
|
||||||
onMoved();
|
onMoved();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMeldung(err instanceof ApiError ? err.message : 'Herauslösen fehlgeschlagen');
|
setMeldung(err instanceof ApiError ? err.message : 'Herauslösen fehlgeschlagen');
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ export function ClassifyModal({
|
|||||||
const [name, setName] = useState(thread.title);
|
const [name, setName] = useState(thread.title);
|
||||||
const [predecessorId, setPredecessorId] = useState<number | null>(null);
|
const [predecessorId, setPredecessorId] = useState<number | null>(null);
|
||||||
const [reviveId, setReviveId] = useState<number | null>(null);
|
const [reviveId, setReviveId] = useState<number | null>(null);
|
||||||
const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_KULANZ');
|
// Der Regelfall ist das bezahlte Redesign. Kulanz ist die Ausnahme und
|
||||||
|
// wird bewusst vergeben - sonst entstehen Kulanz-Buchungen, die niemand
|
||||||
|
// gewollt hat.
|
||||||
|
const [useKind, setUseKind] = useState<'REDESIGN_KULANZ' | 'REDESIGN_PAID'>('REDESIGN_PAID');
|
||||||
const [characters, setCharacters] = useState<Character[]>([]);
|
const [characters, setCharacters] = useState<Character[]>([]);
|
||||||
const [inactive, setInactive] = useState<Character[]>([]);
|
const [inactive, setInactive] = useState<Character[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -167,19 +170,19 @@ export function ClassifyModal({
|
|||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="reviveKind"
|
name="reviveKind"
|
||||||
checked={useKind === 'REDESIGN_KULANZ'}
|
checked={useKind === 'REDESIGN_PAID'}
|
||||||
onChange={() => setUseKind('REDESIGN_KULANZ')}
|
onChange={() => setUseKind('REDESIGN_PAID')}
|
||||||
/>
|
/>
|
||||||
<span>Kulanz-Redesign</span>
|
<span>Bezahltes Redesign</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="radio">
|
<label className="radio">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="reviveKind"
|
name="reviveKind"
|
||||||
checked={useKind === 'REDESIGN_PAID'}
|
checked={useKind === 'REDESIGN_KULANZ'}
|
||||||
onChange={() => setUseKind('REDESIGN_PAID')}
|
onChange={() => setUseKind('REDESIGN_KULANZ')}
|
||||||
/>
|
/>
|
||||||
<span>Bezahltes Redesign</span>
|
<span>Kulanz-Redesign</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -213,19 +216,19 @@ export function ClassifyModal({
|
|||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="useKind"
|
name="useKind"
|
||||||
checked={useKind === 'REDESIGN_KULANZ'}
|
checked={useKind === 'REDESIGN_PAID'}
|
||||||
onChange={() => setUseKind('REDESIGN_KULANZ')}
|
onChange={() => setUseKind('REDESIGN_PAID')}
|
||||||
/>
|
/>
|
||||||
<span>Kulanz-Redesign</span>
|
<span>Bezahltes Redesign</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="radio">
|
<label className="radio">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
name="useKind"
|
name="useKind"
|
||||||
checked={useKind === 'REDESIGN_PAID'}
|
checked={useKind === 'REDESIGN_KULANZ'}
|
||||||
onChange={() => setUseKind('REDESIGN_PAID')}
|
onChange={() => setUseKind('REDESIGN_KULANZ')}
|
||||||
/>
|
/>
|
||||||
<span>Bezahltes Redesign</span>
|
<span>Kulanz-Redesign</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const SOURCE_LABEL: Record<CreditSource, string> = {
|
|||||||
CC_GRANT: 'CC-Guthaben (von Hand)',
|
CC_GRANT: 'CC-Guthaben (von Hand)',
|
||||||
KULANZ: 'Kulanz',
|
KULANZ: 'Kulanz',
|
||||||
ADJUSTMENT: 'Korrektur',
|
ADJUSTMENT: 'Korrektur',
|
||||||
|
CONVERSION: 'Umwandlung',
|
||||||
CONSUME: 'Verbrauch',
|
CONSUME: 'Verbrauch',
|
||||||
REVERSAL: 'Storno',
|
REVERSAL: 'Storno',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type CreditSource =
|
|||||||
| 'CC_GRANT'
|
| 'CC_GRANT'
|
||||||
| 'KULANZ'
|
| 'KULANZ'
|
||||||
| 'ADJUSTMENT'
|
| 'ADJUSTMENT'
|
||||||
|
| 'CONVERSION'
|
||||||
| 'CONSUME'
|
| 'CONSUME'
|
||||||
| 'REVERSAL';
|
| 'REVERSAL';
|
||||||
export type CharacterStatus = 'ACTIVE' | 'INACTIVE';
|
export type CharacterStatus = 'ACTIVE' | 'INACTIVE';
|
||||||
|
|||||||
@@ -601,7 +601,7 @@ export default function PlayerDetail() {
|
|||||||
submitLabel="Redesign durchführen"
|
submitLabel="Redesign durchführen"
|
||||||
hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`}
|
hint={`"${dialog.character.name}" wird inaktiv (redesignt), der neue CC tritt an seine Stelle.`}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']}
|
withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']}
|
||||||
balances={detail.balances}
|
balances={detail.balances}
|
||||||
onClose={() => setDialog({ type: 'none' })}
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
onSubmit={(values) =>
|
onSubmit={(values) =>
|
||||||
@@ -666,7 +666,7 @@ export default function PlayerDetail() {
|
|||||||
submitLabel="Redesign durchführen"
|
submitLabel="Redesign durchführen"
|
||||||
hint={`${playerName} steigt aus "${dialog.character.name}" aus und bekommt dafür einen eigenen CC. Die übrigen Nutzer bleiben unberührt. Ein Redesign tauscht einen Platz, es kommt keiner hinzu.`}
|
hint={`${playerName} steigt aus "${dialog.character.name}" aus und bekommt dafür einen eigenen CC. Die übrigen Nutzer bleiben unberührt. Ein Redesign tauscht einen Platz, es kommt keiner hinzu.`}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
withKind={['REDESIGN_KULANZ', 'REDESIGN_PAID', 'CC']}
|
withKind={['REDESIGN_PAID', 'REDESIGN_KULANZ', 'CC']}
|
||||||
balances={detail.balances}
|
balances={detail.balances}
|
||||||
onClose={() => setDialog({ type: 'none' })}
|
onClose={() => setDialog({ type: 'none' })}
|
||||||
onSubmit={(values) =>
|
onSubmit={(values) =>
|
||||||
@@ -1069,6 +1069,12 @@ function CharacterFormModal({
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{useKind === 'CC' && (
|
||||||
|
<p className="muted small">
|
||||||
|
Ein CC-Guthaben für ein Redesign wird umgewandelt: ein Platz weniger, ein
|
||||||
|
bezahltes Redesign mehr.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user