Kampagnen-PDF: Layout-Fehler, die erst der Ausdruck gezeigt hat
13.09.2026 · Ingo Wiarda · e4770f5
Drei Sachen, die in den Testtexten richtig aussahen und auf dem Blatt falsch:
- Bild und Unterschrift gehörten nicht zusammen. Als zwei Elemente hinzugefügt
sind sie zwei Dinge, die einzeln platziert werden: das zweite Bild einer Szene
passte nicht mehr, floss auf die nächste Seite und ließ seine Unterschrift unter
der ersten zurück. Beides steckt jetzt in einer Zelle mit setKeepTogether.
- Weil das Bild erst beim nächsten Seitenanfang platziert wurde, landete es auf
der Anhangsseite - über der Loot-Tabelle, unter einer Fußzeile ohne Szenenname.
Mit dem Block zusammen passiert der Umbruch dort, wo die Szene gezeichnet wird,
und die Seite trägt wieder ihren Namen.
- Der Statblock eines Mobs war über zwei Seiten zerrissen (Kopf auf der einen,
Tabellen auf der anderen) und der Name stand doppelt da: einmal als eigene
Überschrift, einmal aus dem Charakterbogen selbst. Jetzt eine Seite je Mob und
keine eigene Überschrift mehr.
Die vier Tests, die dabei rot wurden, waren wieder an Seitenindizes gebunden -
dieselbe Falle wie bei der Fußzeile. Sie fragen jetzt das Dokument statt eine
Seitennummer: "welche Seite enthält den Statblock" statt "die letzte Seite".
Gefunden, indem die Seiten als PNG gerendert und angesehen wurden. Kein
Textauszug hätte das gezeigt: die Unterschrift stand ja da, nur eben ohne Bild.
Claude-Session: https://claude.ai/code/session_018UUooLMDSwYZMY3SCQyx8F
Kampagnen-PDF: Lesezeichen je Szene und für den Anhang
12.09.2026 · Ingo Wiarda · 73b2777
Letztes Inkrement aus docs/plans/kampagnen-pdf.adoc. Die Seitenleiste jedes
Readers listet jetzt die Szenen in Reihenfolge und danach den Anhang; ohne Anhang
gibt es auch keinen Eintrag dafür. Angelegt wird ein Eintrag direkt hinter dem
Umbruch, der seine Seite beginnt - das Ziel ist die gerade aktuelle Seite.
Kein Inhaltsverzeichnis mit Seitenzahlen: die wären erst bekannt, wenn die Seiten
existieren. Auf Papier macht die Fußzeile die Arbeit, im Reader die Seitenleiste.
Der Test prüft die Ziele mit und nicht nur die Titel - ein Lesezeichen, das auf
Seite 1 zeigt, käme durch eine Titelprüfung durch. Geprüft wird "das erste zeigt
auf Seite 2" und "jedes weitere zeigt tiefer hinein", nicht feste Seitenzahlen:
dieselbe Lehre wie bei der Fußzeile, wo feste Indizes zweimal das Falsche
behauptet haben.
Claude-Session: https://claude.ai/code/session_018UUooLMDSwYZMY3SCQyx8F
Fix: Fußzeile merkt die Szene beim Seitenbeginn, nicht beim Seitenende
12.09.2026 · Ingo Wiarda · 2a61eff
Zweiter Versuch an derselben Stelle, und diesmal ohne Verlass auf den Zeitpunkt.
Der erste Fix verschob nur das Löschen des Etiketts; auf play blieb damit die
Folgeseite einer Szene ohne Namen. Der Grund: wann openpdf eine Fußzeile
tatsächlich schreibt, ist nicht dasselbe für eine Seite, die von selbst voll
wurde, und eine, die ein ausdrücklicher Umbruch beendet. Ein Feld, das beim
Seitenende gelesen wird, liefert deshalb für einen Teil der Seiten die richtige
Antwort und für den Rest die nächste Szene oder keine.
Jetzt wird beim Seitenbeginn (onStartPage) festgehalten, zu welcher Szene die
Seite gehört, und beim Seitenende nur nachgesehen. Der Seitenbeginn ist
eindeutig: welche Szene gerade gezeichnet wird, besitzt die Seite.
Der Test prüft nicht mehr Seitenindizes, sondern die Folge der Etiketten über das
ganze Dokument - nichts auf der Titelseite, dann je Szene ein zusammenhängender
Lauf in Reihenfolge, dann nichts mehr. Die Indizes waren der Grund, warum die
ersten zwei Versuche grün waren: eine Szene mit Bildern läuft über, und ein
Anhang mit Statblock ebenfalls, also weiß dieser Test die Seitenzahl nicht mehr.
Claude-Session: https://claude.ai/code/session_018UUooLMDSwYZMY3SCQyx8F
Fix: die letzte Szene verlor ihren Namen in der Fußzeile
12.09.2026 · Ingo Wiarda · 23359f7
Auf play sichtbar geworden: eine Kampagne mit einer einzigen Szene hatte auf
keiner Seite den Szenennamen in der Fußzeile, obwohl der Test "die Folgeseite
nennt die Szene" grün war.
Die Fußzeile einer Seite wird geschrieben, wenn die Seite *endet* - für die letzte
Szene also erst im Seitenumbruch des Anhangs (oder in close()). setScene(null)
stand vor diesem Umbruch und hat das Etikett genau dieser Seite gelöscht. Bei
einer Kampagne mit einer Szene ist das jede Szenenseite. Das Löschen steht jetzt
hinter dem Umbruch, im Anhang selbst.
Der alte Test prüfte nur eine Folgeseite der *ersten* Szene und war deshalb grün.
Der neue prüft jede Szenenseite, ausdrücklich auch die letzte, und dass Titelseite
und Anhang keinen Szenennamen tragen.
Claude-Session: https://claude.ai/code/session_018UUooLMDSwYZMY3SCQyx8F
Kampagnen-PDF: Bilder der Szenen, und die Fußzeile nennt die Szene
12.09.2026 · Ingo Wiarda · 3bf43ec
Inkrement 5 aus docs/plans/kampagnen-pdf.adoc.
Bilder werden eingebettet, mit Namen als Unterschrift und, wo die Spieler sie
noch nicht gesehen haben, mit "Erst auf Zuruf". Nicht die gespeicherte Vorschau:
die ist 256 px auf der langen Seite, also etwa 43 dpi auf 150 mm Druckbreite -
klotzig auf einem Blatt, das man ausdruckt und mitnimmt. Genommen wird das
Original, beim Rendern auf 1400 px verkleinert (rund 240 dpi) und nur, wenn es
überhaupt größer ist; Verkleinertes als PNG bei Alphakanal, sonst als JPEG, weil
ein Foto als PNG neu kodiert größer wird als das Original.
Dekodiert und skaliert wird einmal, vor beiden Zeichendurchläufen - die
Seitenzählung würde sonst die teure Hälfte doppelt machen. Das war beim
Zwei-Durchlauf-Entscheid schon vorgemerkt.
Der Test zählt Bild-XObjects je Seite, statt nach Unterschriften zu suchen: eine
Unterschrift ist Text und würde auch ohne Bild gedruckt. Das hat dann auch etwas
gefunden - eine Szene mit Bildern passt nicht mehr auf eine Seite, das zweite
Bild landet auf einer Folgeseite. Deshalb nennt die Fußzeile jetzt die Szene:
sonst trägt so ein Blatt ein Bild und eine Tabelle unter keiner Überschrift und
ist auf einem Stapel Ausdrucke nicht zuzuordnen.
Außerdem im Plan und am Modell festgehalten, wofür das Ganze da ist: der
Spielleiter baut hier und nimmt das Dokument an einen echten Tisch mit. Der
Handout-Export ist eine andere Sache und ist zurückgestellt, bis es die
Markierung gibt, auf der er beruhen müsste - revealed heißt "im Raum gezeigt",
nicht "dies ist ein Handout". Audience.HANDOUT bleibt als getestetes Gerüst
stehen, ohne Endpunkt.
Claude-Session: https://claude.ai/code/session_018UUooLMDSwYZMY3SCQyx8F
Kampagnen-PDF: Mobs und Loot je Szene, Statblöcke im Anhang
12.09.2026 · Ingo Wiarda · a11c034
Inkrement 3 aus docs/plans/kampagnen-pdf.adoc.
Je Szene eine Mob-Tabelle ("Brückentroll ×3") und eine Loot-Tabelle mit Anzahl,
Sichtbarkeit und Träger. Die Sichtbarkeit steht in den Worten, die der Editor für
die beiden Zustände benutzt - "liegt sichtbar da" gegen "muss gefunden werden" -
statt als nacktes Flag.
Dahinter der Anhang: die Loot-Liste der Kampagne und je Mob ein Statblock. Im
Anhang und nicht in der Szene, weil ein Mob in mehreren Szenen auftritt und
überall dieselben Werte hat; die Szene sagt ×3, der Block steht einmal.
Der Statblock ist der des Charakterbogens. Dafür ist nicht jede Tabelle
freigelegt, sondern eine Naht: CreaturePdfService.appendSheet(document, creature)
hängt die Blöcke in ein Dokument, das jemand anders geöffnet hat, und generate()
benutzt jetzt dieselbe Methode. So ist ein Statblock einmal definiert; sechs
einzelne Bauer hätten jedem Aufrufer erlaubt, eine andere Auswahl zu treffen.
Dass der Bogen selbst unverändert aussieht, prüfen die fünf Tests, die es dafür
schon gab.
Claude-Session: https://claude.ai/code/session_018UUooLMDSwYZMY3SCQyx8F
Kampagnen-PDF: Grundgerüst und Spielleiter-Endpunkt
12.09.2026 · Ingo Wiarda · 682e1d0
Inkrement 2 aus docs/plans/kampagnen-pdf.adoc. CampaignPdfService zeichnet ein
CampaignDocument: Titelseite mit Name, Spielsystem, Fassung und Beschreibung,
dann je Szene eine Seite mit Beschreibung, Spielleiter-Notiz im grauen Kasten und
den Textblöcken - unenthüllte mit "Erst auf Zuruf" beschriftet, in den Worten,
die der Editor für diesen Zustand benutzt.
Im Renderer steht kein einziges if (handout). Was eine Zielgruppe sehen darf, hat
CampaignDocumentService entschieden; hier wird nur gezeichnet. Genau deshalb
existiert die Trennung.
Die Seitenzahl ist anders gelöst als der Plan vorschlug: das Dokument wird
zweimal gezeichnet, einmal zum Zählen, einmal mit der Gesamtzahl. Der klassische
PdfTemplate-Trick spart den Durchlauf und kostet einen Schlüssel, der mitten im
Satz endet ("Seite {0} von"), sowie einen Off-by-one in getPageNumber(). Ein
Textdokument zweimal zu zeichnen ist billig; der Test prüft "Seite 1 von 3" bis
"Seite 3 von 3", wo ein Off-by-one auffällt.
GET /campaign/{id}/export/pdf in SceneController, mit ensureAuthor wie die
beiden anderen Exporte, plus Knopf auf campaign/edit.
Außerdem korrigiert: der Kommentar an collect() nannte "Beschreibungen bleiben im
Handout" eine Ermessensfrage. Ist sie nicht - der Editor beschriftet das Feld als
"Beschreibung für die Spieler" und die Notiz als "sieht nur der Spielleiter". Die
Trennung folgt den Feldern.
Claude-Session: https://claude.ai/code/session_018UUooLMDSwYZMY3SCQyx8F
CampaignDocument: was in einem Kampagnen-Dokument steht, je Zielgruppe
12.09.2026 · Ingo Wiarda · ccfc695
Inkrement 1 aus docs/plans/kampagnen-pdf.adoc, die Leseseite der
Kampagnen-Ausgabe. Noch kein PDF.
CampaignDocument sind Records ohne Verhalten; Bilder tragen ids statt Bytes,
weil die Originale den Großteil eines Kampagnen-Archivs ausmachen und ein Modell,
das sie hält, zum Wegwerfen gebaut wäre. CampaignDocumentService.collect
entscheidet *einmal*, was eine Zielgruppe sehen darf, und benutzt dieselben
Abfragen wie CampaignExportService - eine zweite Menge wäre eine zweite Antwort
auf "was steht in dieser Kampagne, in welcher Reihenfolge".
Das Handout verliert: jede Spielleiter-Notiz, jeden Textblock und jedes Bild
ohne revealed, und Mobs und Loot vollständig - beide Pools und ihr Auftreten je
Szene. Dass drei Trolle an der Brücke warten, ist Inhalt, nicht Formatierung.
Szenenbeschreibungen bleiben drin. Das ist die eine Ermessensentscheidung, sie
steht mit Begründung am Javadoc von collect.
Audience heißt Audience und nicht Fassung: der Plan hatte deutsche Konstanten
vorgeschlagen und damit gegen die eigene Regel aus CLAUDE.md verstoßen; der Plan
ist mitkorrigiert.
Der Sammler behält den Namens-Fallback von CampaignExportService, aber mit
richtigem Kommentar: er ist nicht erreichbar. Die Pool-Abfrage joint creature
inner, und deleteCreatureCascading löscht die Pool-Zeilen vor der Kreatur - ein
Eintrag ohne Vorlage ist kein Zustand, den es gibt. Ein Test hält das fest.
Claude-Session: https://claude.ai/code/session_018UUooLMDSwYZMY3SCQyx8F
Zauber auf dem Charakterbogen
12.09.2026 · Ingo Wiarda · 10dd4d2
Die dritte Angriffsquelle, die noch fehlte. Eigener Block statt einer Zeile in
den Angriffen, weil zwei Spalten dazukommen, die Waffe und natürlicher Angriff
nicht haben: Manakosten und die Stufe der Zauberfertigkeit, mit der skalierende
Würfel multipliziert werden.
Der Angriffswert kommt aus demselben Aufruf wie bei den anderen Quellen, mit
AttackSource.spell. castableSpells ist bereits auf Kampfmagie eingegrenzt; sein
affordable-Flag wird bewusst ignoriert, weil es gegen den aktuellen Manavorrat
vergleicht - der ist während der Sitzung, für die man den Bogen druckt, ständig
ein anderer.
Ohne Zauber entfällt der Block ganz, dieselbe Entscheidung wie beim Mana im
Kopf: leer stünde er sonst auf jedem Bogen jedes Systems ohne Magie. Ein Test
prüft beides - die ganze Zeile für den Magier, die Abwesenheit beim Söldner.
Die Würfel-Darstellung ist jetzt damageText() und damit für Waffen, natürliche
Angriffe und Zauber dieselbe.
Claude-Session: https://claude.ai/code/session_018UUooLMDSwYZMY3SCQyx8F
Angriffe je Quelle auf dem Charakterbogen
12.09.2026 · Ingo Wiarda · 354172f
Der aggregierte Angriffswert sagt für eine unbewaffnete Kreatur 0 und für eine
bewaffnete nur den besten Beitrag - auf dem gedruckten Bogen stand damit
"Angriff 0, Schaden 0" bei einer Kreatur mit Schwert und Biss.
CreaturePdfService.attacksTable gibt jetzt je ausgerüsteter Waffe und je
natürlichem Angriff eine Zeile: Angriffswert über
combatValue(creature, ATTACK, source), Würfel über damageDice - dieselben
Aufrufe in derselben Reihenfolge wie CharacterDerivedExportService, damit Bogen
und Foundry-Export sich nicht über den Wert eines Schlages widersprechen.
Zwei Feinheiten, die dort schon dokumentiert sind: natürliche Angriffe werden je
Fertigkeit genommen, nicht je Schadenszeile (sonst erscheint ein Biss mit
getypter und ungetypter Komponente zweimal, während der Server beide als einen
Schlag würfelt), und findEquipped statt einer eigenen Filterung, damit
"ausgerüstet" keine zweite Antwort bekommt.
Zauber bleiben außen vor: sie brauchen Manakosten und eine eigene Spalte.
Der Test prüft ganze Zeilen ("Langschwert -2 1W6", "Biss -3 1W4"), weil erst die
unterschiedlichen Zahlen zeigen, dass je Quelle aufgelöst wird.
Claude-Session: https://claude.ai/code/session_018UUooLMDSwYZMY3SCQyx8F