diff --git a/README.md b/README.md index bc580e9..2e946bc 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,10 @@ Bei einem **Gruppen-CC** gilt das für die eigene Buchung: Wer ihn in seine Kett damit seinen eigenen Beitritt um - die Buchung des Erstellers bleibt unberührt, für den ist es sein eigener Platz in seiner eigenen Kette. Einreihen darf, wer an beidem beteiligt war. +**Was einem Spieler gehört, steht immer in seinen Ketten** - auch wenn seine Mitgliedschaft +daran beendet wurde. Es bleibt sein CC und seine Geschichte. Unter *Früher genutzt* landen nur +CCs, die jemand anderem gehören und zu keinem seiner Plätze mehr führen. + Am Fuß einer Kette kann ein CC stehen, der jemand anderem gehört - der Gruppen-CC, aus dem sich der Spieler herausredesignt hat. Er trägt das Kennzeichen *war dabei* samt Zeitraum und Besitzer, ist aber **genauso bedienbar wie jeder andere Eintrag**: ziehen, bearbeiten, diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index 382d7e3..c9d8b22 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -1417,10 +1417,16 @@ export async function addMember( // im laufenden Betrieb auch - es nimmt die Gruppe mit. const schonInDerKette = await warSchonInDerKette(character, playerId); + // Ebenso der Besitzer an seinem eigenen CC: Seine Buchung hängt daran, + // seit er ihn angelegt hat. Wer sich dort wieder einträgt - etwa weil er + // sich versehentlich selbst ausgetragen hat -, zahlt nicht ein zweites + // Mal für denselben Platz. + const istBesitzer = character.playerId === playerId; + // Sonst gilt: an einem laufenden CC kostet ein Platz etwas. An einem // inaktiven wird nachgetragen, was einmal war - dort ist die Angabe // freigestellt, weil die Buchung längst im Konto stehen kann. - if (!historisch && !schonInDerKette && input.useKind === undefined) { + if (!historisch && !schonInDerKette && !istBesitzer && input.useKind === undefined) { throw badRequest( 'NEED_KIND', 'Ein Platz an einem laufenden CC kostet ein Guthaben - bitte angeben, welches', diff --git a/server/test/gruppen.test.ts b/server/test/gruppen.test.ts index a7f5223..b615d81 100644 --- a/server/test/gruppen.test.ts +++ b/server/test/gruppen.test.ts @@ -735,3 +735,37 @@ describe('Einzelne Mitglieder fallen aus', () => { ); }); }); + +describe('Der eigene CC bleibt der eigene', () => { + it('lässt den Besitzer sich ohne neue Buchung wieder eintragen', async () => { + // Kommt beim Nacherfassen vor: Der Besitzer ist aus seinem eigenen CC + // ausgetragen worden. Seine Buchung hängt trotzdem daran, seit er ihn + // angelegt hat - ein zweites Mal zahlt er nicht. + const besitzer = await spielerMitPaket('Selbst ausgetragen'); + const zweiter = await spielerMitPaket('Bleibt drin'); + const { character } = await characters.createCharacter(besitzer, { name: 'Seiner' }, ctx); + await characters.addMember(character.id, zweiter, { useKind: 'CC' }, ctx); + + await characters.removeMember(character.id, besitzer, 'Versehen', ctx); + const vorher = await credits.getBalances(besitzer); + + await characters.addMember(character.id, besitzer, {}, ctx); + + assert.deepEqual(await credits.getBalances(besitzer), vorher, 'kostet ihn nichts'); + const dabei = await db.prisma.characterMember.findFirstOrThrow({ + where: { characterId: character.id, playerId: besitzer }, + }); + assert.equal(dabei.leftAt, null, 'und er ist wieder dabei'); + }); + + it('verlangt von einem Fremden weiterhin ein Guthaben', async () => { + const besitzer = await spielerMitPaket('Wirt drei'); + const fremder = await spielerMitPaket('Gast drei'); + const { character } = await characters.createCharacter(besitzer, { name: 'Seins' }, ctx); + + await assert.rejects( + () => characters.addMember(character.id, fremder, {}, ctx), + (error: Error & { code?: string }) => error.code === 'NEED_KIND', + ); + }); +}); diff --git a/web/src/components/ChainView.tsx b/web/src/components/ChainView.tsx index a8c1365..ae88fde 100644 --- a/web/src/components/ChainView.tsx +++ b/web/src/components/ChainView.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import MemberList from './MemberList'; import Notice from './Notice'; import { ApiError, api } from '../lib/api'; @@ -50,8 +50,20 @@ function eigeneMitgliedschaft(character: Character, playerId: number) { return (character.members ?? []).find((m) => m.playerId === playerId); } -/// Ein Platz, den der Spieler gerade belegt oder auf den er zurückkehren kann. +/** + * Ein Eintrag, der zu seinen Ketten gehört. + * + * Wem der CC gehört, dem gehört er - auch wenn seine Mitgliedschaft daran + * beendet wurde, etwa weil er sich aus einer Gruppe herausredesignt hat, die + * an seinem eigenen CC hing. Es bleibt seine Geschichte und sein Platz. + * + * Bei fremden CCs entscheidet die Mitgliedschaft: wer noch drin ist oder + * zurückkehren kann, hat dort einen Platz. Wer endgültig raus ist, findet + * den CC unter "Früher genutzt". + */ function istSeinPlatz(character: Character, playerId: number): boolean { + if (character.playerId === playerId) return true; + const eigene = eigeneMitgliedschaft(character, playerId); if (!eigene) return false; return eigene.leftAt === null || eigene.inactiveReason !== null; @@ -121,6 +133,38 @@ export default function ChainView({ }) { const [ziehe, setZiehe] = useState(null); const [ueber, setUeber] = useState(null); + + /** + * Während des Ziehens am Rand scrollen. + * + * Ein Browser scrollt beim Ziehen nicht von selbst. Wer einen Eintrag von + * ganz unten in eine Kette weiter oben bringen will, käme sonst nicht hin - + * er sieht das Ziel gar nicht. + */ + useEffect(() => { + if (!ziehe) return; + + const RAND = 120; + const SCHRITT = 18; + let richtung = 0; + let timer: number | null = null; + + const beiBewegung = (event: DragEvent) => { + if (event.clientY < RAND) richtung = -1; + else if (event.clientY > window.innerHeight - RAND) richtung = 1; + else richtung = 0; + }; + + timer = window.setInterval(() => { + if (richtung !== 0) window.scrollBy(0, richtung * SCHRITT); + }, 16); + + document.addEventListener('dragover', beiBewegung); + return () => { + document.removeEventListener('dragover', beiBewegung); + if (timer !== null) window.clearInterval(timer); + }; + }, [ziehe]); // Eine Meldung zum Ziehen gehört neben die Ketten, nicht an den Seitenkopf: // wer unten zieht, sieht dort oben nichts. const [meldung, setMeldung] = useState(null);