From c82391e73f62b58a420a963ebfa58b4004163aae Mon Sep 17 00:00:00 2001 From: Harlekin Date: Mon, 21 Sep 2026 13:35:48 +0200 Subject: [PATCH] Vergangene Mitgliedschaften sichtbar, Antworten nicht mehr aus dem Cache Wer sich aus einem Gruppen-CC herausredesignt hat, war trotzdem darin - der CC verschwand aber aus seiner Liste. Jetzt steht er am Fuss der Kette, in die der Spieler von dort aus gewechselt ist, gekennzeichnet als "war dabei" und mit dem Hinweis, wem er gehoert und wohin es weiterging. Verschieben oder entscheiden laesst sich daran nichts: es ist der Platz eines anderen. Frueheres, das zu keinem heutigen Platz fuehrt, steht unter "Frueher genutzt". Dasselbe gilt fuer einen gewoehnlichen Austritt. Neu eingetragene CCs tauchten manchmal erst nach einem Neuladen auf. Die Antworten der Schnittstelle trugen keinen Cache-Header, und ohne Angabe darf ein Browser selbst entscheiden, wie lange er eine GET-Antwort aufhebt - dann steht nach dem Anlegen die alte Liste auf dem Schirm. Sie tragen jetzt no-store, was nebenbei verhindert, dass Spielerdaten im Cache liegen bleiben. Ausserdem: die Pfeile zwischen den Kettengliedern sind groesser und tragen die Akzentfarbe - sie sind das Einzige, was die Leserichtung verraet. Und "Zurueknehmen" heisst jetzt "Loeschen", weil genau das passiert. --- README.md | 5 ++ server/src/app.ts | 14 ++++ server/src/services/characters.ts | 15 ++-- server/test/gruppen.test.ts | 53 +++++++++--- server/test/http.test.ts | 8 ++ web/src/components/ChainView.tsx | 134 ++++++++++++++++++++++++++++-- web/src/pages/PlayerDetail.tsx | 6 +- web/src/styles.css | 20 ++++- 8 files changed, 223 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 3207913..31d7fe7 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,11 @@ diese Kette gehörte. Das Verschieben **ordnet nur ein**: Es entscheidet nichts, bucht nichts und ändert an keinem Status etwas. Wer aktiv war, bleibt aktiv. +Am Fuß einer Kette kann ein CC stehen, der jemand anderem gehört - der Gruppen-CC, aus dem sich +der Spieler herausredesignt hat. Er war darin, und das bleibt sichtbar; verschieben oder +entscheiden lässt sich daran nichts, denn es ist der Platz eines anderen. Frühere +Mitgliedschaften, die zu keinem seiner Plätze führen, stehen darunter unter *Früher genutzt*. + ### Verweise an einem CC An jedem Eintrag hängen beliebig viele Verweise - das Steam-Addon des Erstellers, ein Modell, diff --git a/server/src/app.ts b/server/src/app.ts index 00ac561..2b0fcd8 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -80,6 +80,20 @@ export async function buildApp(): Promise { app.addHook('onRequest', enforceCsrf); app.addHook('preHandler', blockUntilPasswordChanged); + /** + * Antworten der Schnittstelle gehören nicht in den Browser-Cache. + * + * Ohne Angabe darf ein Browser selbst entscheiden, wie lange er eine + * GET-Antwort aufhebt. Dann steht nach dem Anlegen eines CCs die alte + * Liste auf dem Schirm, und erst ein Neuladen bringt sie in Ordnung. + * Nebenbei bleiben so keine Spielerdaten im Cache liegen. + */ + app.addHook('onSend', async (request, reply) => { + if (request.url.startsWith('/api/')) { + reply.header('cache-control', 'no-store'); + } + }); + app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { reply.code(error.statusCode).send({ error: error.code, message: error.message }); diff --git a/server/src/services/characters.ts b/server/src/services/characters.ts index 2df998c..b9ca5f4 100644 --- a/server/src/services/characters.ts +++ b/server/src/services/characters.ts @@ -528,18 +528,15 @@ 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. + * Mit dabei ist **jede** Mitgliedschaft, auch eine beendete. Wer aus einem + * Gruppen-CC herausredesignt hat oder ausgetreten ist, war trotzdem darin - + * und das gehört zu seiner Geschichte. Ob daraus ein Anspruch folgt, ist eine + * andere Frage und steht am Grund der Mitgliedschaft; hier geht es darum, was + * überhaupt zu sehen ist. */ export async function listCharacters(playerId: number) { return prisma.character.findMany({ - where: { - members: { - some: { playerId, OR: [{ leftAt: null }, { inactiveReason: { not: null } }] }, - }, - }, + where: { members: { some: { playerId } } }, orderBy: [{ lineageId: 'asc' }, { chainPosition: 'asc' }, { createdAt: 'asc' }], include: { predecessor: { select: { id: true, name: true } }, diff --git a/server/test/gruppen.test.ts b/server/test/gruppen.test.ts index db38056..741c4e9 100644 --- a/server/test/gruppen.test.ts +++ b/server/test/gruppen.test.ts @@ -202,7 +202,7 @@ describe('Gruppen-CCs', () => { assert.deepEqual(nachAustritt, vorAustritt, 'der Platz war bezahlt und bleibt es'); }); - it('zeigt den CC nach dem Austritt nicht mehr beim Aussteiger', async () => { + it('behält den CC nach dem Austritt in der Geschichte des Aussteigers', async () => { const gruender = await spielerMitPaket('Kern'); const kurz = await spielerMitPaket('Kurzzeitig'); const { character } = await characters.createCharacter(gruender, { name: 'Durchlauf' }, ctx); @@ -213,8 +213,15 @@ describe('Gruppen-CCs', () => { await characters.removeMember(character.id, kurz, undefined, ctx); + // Er war darin, und das bleibt so. Dass er keinen Platz mehr belegt, + // steht an der Mitgliedschaft, nicht daran, dass der CC verschwindet. const danach = await characters.listCharacters(kurz); - assert.equal(danach.some((c) => c.id === character.id), false, 'danach nicht mehr'); + const eintrag = danach.find((c) => c.id === character.id); + assert.ok(eintrag, 'der CC gehört weiter zu seiner Geschichte'); + assert.ok( + eintrag?.members.find((m) => m.playerId === kurz)?.leftAt, + 'seine Mitgliedschaft ist beendet', + ); // Beim Gründer bleibt er stehen. const beimGruender = await characters.listCharacters(gruender); @@ -432,11 +439,13 @@ describe('Slots: ein CC-Paket, ein gleichzeitig aktiver CC', () => { ctx, ); - const aktive = (await characters.listCharacters(gast)).filter((c) => c.status === 'ACTIVE'); + const belegt = (await characters.listCharacters(gast)).filter( + (c) => c.status === 'ACTIVE' && c.members.some((m) => m.playerId === gast && !m.leftAt), + ); assert.deepEqual( - aktive.map((c) => c.name), + belegt.map((c) => c.name), ['Die Runde'], - 'ein Platz, ein aktiver CC - der eigene ist dafür weg', + 'ein Platz, ein belegter Platz - der eigene ist dafür weg', ); }); }); @@ -514,12 +523,36 @@ describe('Aus einem Gruppen-CC herausredesignen', () => { ctx, ); - const aktive = (await characters.listCharacters(zwei)).filter((c) => c.status === 'ACTIVE'); - assert.deepEqual( - aktive.map((c) => c.name), - ['Getauscht'], - 'ein Paket, ein aktiver CC - der Gruppenplatz ist dafür weg', + // Sichtbar ist beides - er war ja in der Gruppe. Belegt ist nur einer. + const belegt = (await characters.listCharacters(zwei)).filter( + (c) => c.status === 'ACTIVE' && c.members.some((m) => m.playerId === zwei && !m.leftAt), ); + assert.deepEqual( + belegt.map((c) => c.name), + ['Getauscht'], + 'ein Paket, ein belegter Platz - der Gruppenplatz ist dafür weg', + ); + }); + + it('behält den Gruppen-CC in der Geschichte des Ausgestiegenen', async () => { + const { zwei, character } = await dreierGruppe('Geschichte'); + const neuer = await characters.redesignMembership( + character.id, + zwei, + { name: 'Eigenes Ding', useKind: 'REDESIGN_PAID' }, + ctx, + ); + + const seine = await characters.listCharacters(zwei); + const gruppe = seine.find((c) => c.id === character.id); + assert.ok(gruppe, 'er war in der Gruppe, und das bleibt sichtbar'); + + // Die Spur zeigt, wohin es von dort aus ging - daran hängt die Anzeige + // den alten Eintrag unter die richtige Kette. + const eigene = gruppe?.members.find((m) => m.playerId === zwei); + assert.ok(eigene?.leftAt, 'seine Mitgliedschaft ist beendet'); + assert.equal(eigene?.successorId, neuer.character.id); + assert.equal(eigene?.successor?.name, 'Eigenes Ding'); }); it('weist den letzten Nutzer auf das gewöhnliche Redesign hin', async () => { diff --git a/server/test/http.test.ts b/server/test/http.test.ts index 67236c1..6b55715 100644 --- a/server/test/http.test.ts +++ b/server/test/http.test.ts @@ -302,6 +302,14 @@ describe('Auslieferung der Oberfläche', () => { ); }); + it('lässt Antworten der Schnittstelle gar nicht erst in den Cache', async () => { + // Ohne Angabe entscheidet der Browser selbst, wie lange er eine Antwort + // aufhebt - dann steht nach dem Anlegen eines CCs die alte Liste da. + const response = await app.inject({ method: 'GET', url: '/api/auth/me' }); + + assert.equal(response.headers['cache-control'], 'no-store'); + }); + it('liefert auch Deep-Links ohne Cache aus', async () => { const response = await app.inject({ method: 'GET', url: '/players/1' }); diff --git a/web/src/components/ChainView.tsx b/web/src/components/ChainView.tsx index f24a71c..bf0e4bc 100644 --- a/web/src/components/ChainView.tsx +++ b/web/src/components/ChainView.tsx @@ -32,20 +32,47 @@ export type ChainAction = interface Kette { lineageId: string; glieder: Character[]; + /** + * CCs, in denen der Spieler einmal steckte und die zu dieser Kette führten + * - ein Gruppen-CC, aus dem er sich herausredesignt hat. Sie gehören nicht + * ihm, stehen aber am Anfang seiner Geschichte und deshalb unten an der + * Kette. Verschieben lässt sich daran nichts: es ist der Platz eines + * anderen. + */ + herkunft: Character[]; } -function baueKetten(characters: Character[]): Kette[] { +/** Die Mitgliedschaft dieses Spielers an einem CC. */ +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. +function istSeinPlatz(character: Character, playerId: number): boolean { + const eigene = eigeneMitgliedschaft(character, playerId); + if (!eigene) return false; + return eigene.leftAt === null || eigene.inactiveReason !== null; +} + +function baueKetten(characters: Character[], playerId: number): { + ketten: Kette[]; + vergangen: Character[]; +} { + const eigene = characters.filter((c) => istSeinPlatz(c, playerId)); + const verlassen = characters.filter((c) => !istSeinPlatz(c, playerId)); + const nach = new Map(); - for (const c of characters) { + for (const c of eigene) { const liste = nach.get(c.lineageId) ?? []; liste.push(c); nach.set(c.lineageId, liste); } - return [...nach.entries()] + const ketten: Kette[] = [...nach.entries()] .map(([lineageId, glieder]) => ({ lineageId, glieder: [...glieder].sort((a, b) => a.chainPosition - b.chainPosition), + herkunft: [] as Character[], })) // Ketten mit einem aktiven CC zuerst - daran arbeitet man. .sort((a, b) => { @@ -54,6 +81,22 @@ function baueKetten(characters: Character[]): Kette[] { if (aktivA !== aktivB) return aktivA - aktivB; return (a.glieder[0]?.createdAt ?? '').localeCompare(b.glieder[0]?.createdAt ?? ''); }); + + // Verlassene CCs an die Kette hängen, in die der Spieler von dort aus + // gewechselt ist. Wo sich das nicht sagen lässt, stehen sie für sich. + const vergangen: Character[] = []; + for (const c of verlassen) { + const nachfolgerId = eigeneMitgliedschaft(c, playerId)?.successorId ?? null; + const ziel = + nachfolgerId === null + ? undefined + : ketten.find((k) => k.glieder.some((g) => g.id === nachfolgerId)); + + if (ziel) ziel.herkunft.push(c); + else vergangen.push(c); + } + + return { ketten, vergangen }; } export default function ChainView({ @@ -76,9 +119,9 @@ export default function ChainView({ const [ziehe, setZiehe] = useState(null); const [ueber, setUeber] = useState(null); - const ketten = baueKetten(characters); + const { ketten, vergangen } = baueKetten(characters, playerId); - if (ketten.length === 0) { + if (ketten.length === 0 && vergangen.length === 0) { return (

CCs

@@ -196,7 +239,7 @@ export default function ChainView({ {i < vonObenNachUnten.length - 1 && ( <> {zone( kette.lineageId, @@ -209,14 +252,91 @@ export default function ChainView({ ))} {zone(kette.lineageId, null, `${kette.lineageId}-unten`)} + + {kette.herkunft.map((frueher) => ( +
+ + +
+ ))} ); })} + + {vergangen.length > 0 && ( + <> +

Früher genutzt

+

+ CCs, in denen dieser Spieler einmal steckte, die aber keinem seiner Plätze mehr + vorausgehen. Sie gehören jemand anderem. +

+
+ {vergangen.map((character) => ( + + ))} +
+ + )}
); } +/** + * Ein CC, in dem der Spieler einmal steckte. Er gehört ihm nicht mehr, + * deshalb gibt es hier nichts zu entscheiden und nichts zu verschieben - + * nur nachzulesen, wann er dabei war und wohin es danach ging. + */ +function FruehererEintrag({ + character, + playerId, + onAction, +}: { + character: Character; + playerId: number; + onAction: (action: ChainAction, character: Character) => void; +}) { + const eigene = eigeneMitgliedschaft(character, playerId); + const thread = character.forumThreads?.[0]; + const besitzer = (character.members ?? []).find((m) => m.playerId === character.playerId); + + return ( +
+
+ {thread ? ( + + {character.name} + + ) : ( + {character.name} + )} + war dabei +
+ +

+ {eigene?.joinedAt && `Dabei ab ${formatDate(eigene.joinedAt)}`} + {eigene?.leftAt && ` bis ${formatDate(eigene.leftAt)}`} + {eigene?.successor && ` · weiter als „${eigene.successor.name}"`} + {besitzer && besitzer.playerId !== playerId && ` · gehört ${besitzer.player.displayName}`} +

+ {eigene?.note &&

{eigene.note}

} + +
+ +
+
+ ); +} + function ChainEntry({ character, playerId, @@ -377,7 +497,7 @@ function ChainEntry({ )} {aktiv && ( )} diff --git a/web/src/pages/PlayerDetail.tsx b/web/src/pages/PlayerDetail.tsx index f6246bb..af24cba 100644 --- a/web/src/pages/PlayerDetail.tsx +++ b/web/src/pages/PlayerDetail.tsx @@ -550,9 +550,9 @@ export default function PlayerDetail() { {dialog.type === 'revert' && ( setDialog({ type: 'none' })} @@ -563,7 +563,7 @@ export default function PlayerDetail() { method: 'POST', body: { reason }, }), - `"${dialog.character.name}" wurde zurückgenommen.`, + `"${dialog.character.name}" wurde gelöscht.`, ) } /> diff --git a/web/src/styles.css b/web/src/styles.css index 9ab5ea3..428ff1f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1168,12 +1168,26 @@ textarea { letter-spacing: -0.1em; } -/* Der Pfeil zeigt zum jüngeren Eintrag, also nach oben. */ +/* Der Pfeil zeigt zum jüngeren Eintrag, also nach oben. Er ist bewusst + deutlich: er ist das Einzige, was die Leserichtung der Kette verrät. */ .chain-arrow { text-align: center; - color: var(--text-muted); + color: var(--accent); + font-size: 1.4rem; line-height: 1; - padding: 0.2rem 0; + padding: 0.35rem 0; + letter-spacing: 0; +} + +/* Ein CC, der jemand anderem gehört und nur zur Geschichte beiträgt. */ +.chain-entry.is-past { + opacity: 0.7; + border-style: dotted; + cursor: default; +} + +.chain-past-head { + margin: 1.5rem 0 0.25rem; } /* Erscheint nur, solange etwas gezogen wird. */