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. */