Files
troxler-skat/card-game/make_rules.js
2026-05-31 14:39:45 -05:00

508 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fs = require('fs');
const path = require('path');
const PDFDocument = require('pdfkit');
const sharp = require('sharp');
const QRCode = require('qrcode');
const { SUITS, buildCardSVG } = require('./trox_card');
/* ============================================================
TROX Erweiterte offizielle Spielanleitung (EN)
Node.js / pdfkit. Erzeugt TROX_Rules_Extended_EN.pdf
node make_rules.js
Codes: R=Red(Trump), Y=Yellow, G=Green, B=Black.
Beispiele zeigen echte Mini-Karten (aus trox_card.js).
Am Ende QR-Code zur Scorepad-Webseite.
============================================================ */
// ====== HIER ANPASSEN: Ziel-URL des QR-Codes (Webseite noch zu erstellen) ======
const SCOREPAD_URL = 'https://trox.example.com/scorepad';
// ===============================================================================
// ---- Palette ----
const C = {
gold: '#AA7C11',
goldLt: '#D4AF37',
dark: '#111827',
red: '#E53935',
yellow: '#C9A300', // dunkler für Lesbarkeit auf Weiß
green: '#43A047',
black: '#212121',
greyBg: '#F3F4F6',
greyLn: '#D1D5DB',
grey: '#6B7280',
bodyTx: '#212121',
white: '#FFFFFF',
};
// Mapping: Kartenkürzel (R/Y/G/B) -> Farbname im Modul
const COLOR_BY_CODE = { R: 'Rot', Y: 'Gelb', G: 'Gruen', B: 'Schwarz' };
// Cache für gerenderte Mini-Karten-PNG-Buffer, key = "R11"
const CHIP_CACHE = {};
async function renderChip(code) {
if (CHIP_CACHE[code]) return CHIP_CACHE[code];
const m = code.match(/^([RYGB])(\d+)$/);
if (!m) return null;
const colorName = COLOR_BY_CODE[m[1]];
const num = parseInt(m[2], 10);
// ECHTE Karte aus dem gemeinsamen Modul, klein gerendert -> identische Optik
const svg = buildCardSVG(colorName, num, { width: 694, height: 1069, safe: 35, cornerRadius: 35 });
const buf = await sharp(Buffer.from(svg), { density: 150 })
.resize(74, 114, { fit: 'fill' })
.flatten({ background: '#ffffff' })
// feiner grauer Rahmen, damit die Karte sich vom weißen Hintergrund abhebt
.extend({ top: 2, bottom: 2, left: 2, right: 2, background: '#B8Bec9' })
.png().toBuffer();
CHIP_CACHE[code] = buf;
return buf;
}
async function preRenderChips(codes) {
for (const c of codes) await renderChip(c);
}
// Sammelt automatisch alle Chip-Codes aus den Tabellen-Datenstrukturen,
// damit keine Karte vergessen werden kann (Bugfix gegen fehlende Chips).
function collectChipCodes(...tables) {
const set = new Set();
const walk = (cell) => {
if (cell && typeof cell === 'object' && Array.isArray(cell.chips)) {
cell.chips.forEach(c => set.add(c));
}
};
for (const tbl of tables) for (const row of tbl) for (const cell of row) walk(cell);
return [...set];
}
let QR_BUFFER = null;
async function renderQR(url) {
QR_BUFFER = await QRCode.toBuffer(url, {
type: 'png', margin: 1, width: 300,
color: { dark: '#111827', light: '#ffffff' },
errorCorrectionLevel: 'M',
});
}
const PAGE = { size: 'LETTER', margins: { top: 43, bottom: 43, left: 54, right: 54 } };
const doc = new PDFDocument({ ...PAGE, info: { Title: 'TROX Official Game Rules', Author: 'Bay Area Affiliates' } });
const OUT = path.join(__dirname, 'TROX_Rules_Extended_EN.pdf');
doc.pipe(fs.createWriteStream(OUT));
const PW = doc.page.width;
const ML = PAGE.margins.left;
const MR = PAGE.margins.right;
const CONTENT_W = PW - ML - MR;
const BOTTOM = doc.page.height - PAGE.margins.bottom;
// ---- Helpers --------------------------------------------------
function ensureSpace(h) {
if (doc.y + h > BOTTOM) doc.addPage();
}
function h1(text) {
ensureSpace(40);
doc.moveDown(0.4);
doc.fillColor(C.gold).font('Helvetica-Bold').fontSize(15).text(text, ML, doc.y);
doc.moveDown(0.2);
}
function h2(text) {
ensureSpace(28);
doc.moveDown(0.3);
doc.fillColor(C.dark).font('Helvetica-Bold').fontSize(11.5).text(text, ML, doc.y);
doc.moveDown(0.15);
}
function hr(color = C.goldLt, thickness = 1.2) {
const y = doc.y + 2;
doc.save().moveTo(ML, y).lineTo(PW - MR, y).lineWidth(thickness).strokeColor(color).stroke().restore();
doc.y = y + 8;
}
// Rich body text with simple **bold** and *italic* markup
function body(text, opts = {}) {
const size = opts.size || 10;
const indent = opts.indent || 0;
const color = opts.color || C.bodyTx;
const x = ML + indent;
const w = CONTENT_W - indent;
ensureSpace(size + 6);
doc.fontSize(size);
const segments = parseMarkup(text);
// first segment sets position; continued segments flow
let first = true;
for (let i = 0; i < segments.length; i++) {
const s = segments[i];
const last = i === segments.length - 1;
doc.font(s.font).fillColor(s.color || color);
if (first) {
doc.text(s.t, x, doc.y, { width: w, continued: !last, align: opts.align || 'left', lineGap: 2 });
first = false;
} else {
doc.text(s.t, { continued: !last, lineGap: 2 });
}
}
doc.moveDown(opts.spaceAfter != null ? opts.spaceAfter : 0.45);
}
function bullet(label, text, opts = {}) {
const indent = 16;
const x = ML + indent;
const w = CONTENT_W - indent;
ensureSpace(16);
doc.fontSize(10);
// bullet dot
doc.font('Helvetica').fillColor(C.bodyTx).text('\u2022 ', ML + 4, doc.y, { continued: true });
if (label) doc.font('Helvetica-Bold').fillColor(C.bodyTx).text(label + ': ', { continued: true });
const segs = parseMarkup(text);
for (let i = 0; i < segs.length; i++) {
const s = segs[i];
doc.font(s.font).fillColor(s.color || C.bodyTx).text(s.t, { continued: i < segs.length - 1, lineGap: 2 });
}
doc.moveDown(0.3);
}
function note(text) {
// light gold box-ish note (just indented italic-ish)
const x = ML + 8;
ensureSpace(20);
doc.fontSize(9.5);
const segs = parseMarkup(text);
let first = true;
for (let i = 0; i < segs.length; i++) {
const s = segs[i];
doc.font(s.font).fillColor(s.color || C.dark);
if (first) { doc.text(s.t, x, doc.y, { width: CONTENT_W - 16, continued: i < segs.length - 1, lineGap: 2 }); first = false; }
else doc.text(s.t, { continued: i < segs.length - 1, lineGap: 2 });
}
doc.moveDown(0.45);
}
function small(text) {
doc.font('Helvetica-Oblique').fontSize(8.5).fillColor(C.grey)
.text(text, ML, doc.y, { width: CONTENT_W, lineGap: 1 });
doc.moveDown(0.35);
}
// Markup parser: **bold**, *italic*, ~color:#hex|text~
function parseMarkup(text) {
const out = [];
// tokenize on ** , * , and ~color|...~
const re = /(\*\*[^*]+\*\*|\*[^*]+\*|~[^~]+~)/g;
let last = 0, m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) out.push({ t: text.slice(last, m.index), font: 'Helvetica' });
const tok = m[0];
if (tok.startsWith('**')) out.push({ t: tok.slice(2, -2), font: 'Helvetica-Bold' });
else if (tok.startsWith('~')) {
const inner = tok.slice(1, -1);
const [col, ...rest] = inner.split('|');
out.push({ t: rest.join('|'), font: 'Helvetica-Bold', color: col });
} else out.push({ t: tok.slice(1, -1), font: 'Helvetica-Oblique' });
last = re.lastIndex;
}
if (last < text.length) out.push({ t: text.slice(last), font: 'Helvetica' });
return out.length ? out : [{ t: text, font: 'Helvetica' }];
}
// ---- Table renderer ------------------------------------------
// rows: array of arrays of cells. cell = string OR {t, color, font, align, mono}
// OR {chips:['R11','Y18'], align} to render mini-cards.
const CHIP_W = 24, CHIP_H = 37, CHIP_GAP = 5; // echtes Kartenformat (schmal/hoch)
function table(rows, colWidths, opts = {}) {
const headerBg = opts.headerBg || C.dark;
const headerTx = opts.headerTx || C.goldLt;
const zebra = opts.zebra != null ? opts.zebra : true;
const fontSize = opts.fontSize || 9.5;
const padX = 8, padY = 5;
const totalW = colWidths.reduce((a, b) => a + b, 0);
const x0 = ML + (opts.center ? (CONTENT_W - totalW) / 2 : 0);
function isChipCell(cell) { return typeof cell === 'object' && cell && Array.isArray(cell.chips); }
// estimate row heights
function cellH(cell, w) {
if (isChipCell(cell)) return CHIP_H + 2 * padY;
const t = (typeof cell === 'string') ? cell : (cell.t || '');
const f = (typeof cell === 'object' && cell.mono) ? 'Courier-Bold' : 'Helvetica';
doc.font(f).fontSize(fontSize);
return doc.heightOfString(t, { width: w - 2 * padX });
}
const rowHeights = rows.map((r) =>
Math.max(...r.map((c, ci) => cellH(c, colWidths[ci]))) + 2 * padY
);
const totalH = rowHeights.reduce((a, b) => a + b, 0);
ensureSpace(totalH + 4);
let y = doc.y;
for (let ri = 0; ri < rows.length; ri++) {
const rh = rowHeights[ri];
let x = x0;
const isHeader = ri === 0 && opts.header !== false;
if (isHeader) {
doc.save().rect(x0, y, totalW, rh).fill(headerBg).restore();
} else if (zebra && (ri % 2 === 0)) {
doc.save().rect(x0, y, totalW, rh).fill(C.greyBg).restore();
}
for (let ci = 0; ci < rows[ri].length; ci++) {
const cw = colWidths[ci];
const cell = rows[ri][ci];
if (isChipCell(cell)) {
// Mini-Karten zeichnen, links ausgerichtet (oder zentriert)
const n = cell.chips.length;
const totalChipW = n * CHIP_W + (n - 1) * CHIP_GAP;
let startX = x + padX;
if (cell.align === 'center') startX = x + (cw - totalChipW) / 2;
const cyChip = y + (rh - CHIP_H) / 2;
let cxChip = startX;
for (const code of cell.chips) {
const buf = CHIP_CACHE[code];
if (buf) doc.image(buf, cxChip, cyChip, { width: CHIP_W, height: CHIP_H });
cxChip += CHIP_W + CHIP_GAP;
}
} else {
const t = (typeof cell === 'string') ? cell : (cell.t || '');
let font = (typeof cell === 'object' && cell.font) ? cell.font
: (typeof cell === 'object' && cell.mono) ? 'Courier-Bold'
: isHeader ? 'Helvetica-Bold' : 'Helvetica';
let color = isHeader ? headerTx : (typeof cell === 'object' && cell.color) ? cell.color : C.bodyTx;
const align = (typeof cell === 'object' && cell.align) ? cell.align : 'left';
doc.font(font).fontSize(fontSize).fillColor(color)
.text(t, x + padX, y + padY, { width: cw - 2 * padX, align });
}
x += cw;
}
// grid lines
doc.save().rect(x0, y, totalW, rh).lineWidth(0.5).strokeColor(C.greyLn).stroke();
let cx = x0;
for (let ci = 0; ci < colWidths.length - 1; ci++) {
cx += colWidths[ci];
doc.moveTo(cx, y).lineTo(cx, y + rh).lineWidth(0.5).strokeColor(C.greyLn).stroke();
}
doc.restore();
y += rh;
}
doc.y = y + 6;
doc.x = ML;
}
// ============================================================
// CONTENT
// ============================================================
async function main() {
// QR vorab rendern; Chips werden bei den Beispielen automatisch gesammelt
await renderQR(SCOREPAD_URL);
// ---- Title ----
doc.moveDown(0.2);
doc.font('Helvetica-Bold').fontSize(34).fillColor(C.gold)
.text('TROX', ML, doc.y, { width: CONTENT_W, align: 'center' });
doc.font('Helvetica').fontSize(11).fillColor(C.black)
.text('O F F I C I A L G A M E R U L E S', { width: CONTENT_W, align: 'center', characterSpacing: 1 });
doc.moveDown(0.5);
hr();
// ---- 1. Overview ----
h1('1. Overview & Components');
body('TROX is a tactical trick-taking and bidding game for **2 to 6 players** (optimally balanced for **3 to 4 players**). The game rewards logical deduction, card counting, and precise risk assessment.');
body('The deck consists of **80 cards**, divided into four suits, each numbered from 1 to 20:');
table([
['Suit', 'Role', 'Symbol', 'Code'],
[{ t: 'Red', color: C.red, font: 'Helvetica-Bold' }, 'Permanent TRUMP suit', 'Crown', { t: 'R', font: 'Helvetica-Bold' }],
[{ t: 'Yellow', color: C.yellow, font: 'Helvetica-Bold' }, 'Standard suit', 'Sun', { t: 'Y', font: 'Helvetica-Bold' }],
[{ t: 'Green', color: C.green, font: 'Helvetica-Bold' }, 'Standard suit', 'Leaf', { t: 'G', font: 'Helvetica-Bold' }],
[{ t: 'Black', color: C.black, font: 'Helvetica-Bold' }, 'Standard suit', 'Gear', { t: 'B', font: 'Helvetica-Bold' }],
], [110, 240, 110, 70]);
note('**Red is always trump.** A Red card beats any card of the other three suits, regardless of number.');
// ---- 2. Pyramid ----
h1('2. Game Structure (The Pyramid)');
body('A full game consists of exactly **10 rounds**. The number of cards dealt to each player rises and then falls in a pyramid:');
table([
['Round', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10'],
['Cards', '1', '2', '3', '4', '5', '5', '4', '3', '2', '1'],
], [80, ...Array(10).fill(48)], { header: false, zebra: false, fontSize: 9.5 });
// ---- 3. Bidding ----
h1('3. The Bidding Phase');
body('Before the first trick of a round is played, every player looks at their hand. In clockwise order, each player announces **exactly how many tricks they expect to win** this round. The scorekeeper writes this prediction (the **Bid**) into the small box at the top-left of the player\u2019s cell on the scorepad.');
note('A bid of **0** is allowed and is often very valuable: you score the +10 bonus simply by losing every trick on purpose.');
// ---- 4. Trick-Taking ----
h1('4. Trick-Taking Rules');
bullet('Following suit', 'The player who plays the first card of a trick sets the **lead suit**. Every other player **must follow suit** if they hold a card of that color.');
bullet('Sloughing & trumping', 'Only a player who holds **no** card of the lead suit may either discard a card of another standard suit (a slough) or play a Red card to trump the trick.');
bullet('The trump (Red)', 'Red beats all other suits. If several Red cards land in one trick, the **highest Red number** wins.');
bullet('Winning without trump', 'If no Red card is played, the trick goes to the **highest card of the lead suit**. Cards of other (non-lead, non-Red) colors are worth zero here, no matter how high.');
bullet('Leading the next trick', 'The winner of a trick leads the next one. The very first trick of a round is led by the player who bid first.');
// ---- 5. Scoring ----
h1('5. Scoring & Running Totals');
body('At the end of each round, players count the tricks they won:');
bullet('', 'Each won trick is worth **1 point**.');
bullet('', 'If the bid was **correct**, add a **+10 bonus**. If it was wrong (too high OR too low), the bonus is **0**.');
table([
['Bid', 'Tricks won', 'Result', 'Points (tricks + bonus)'],
['2', '2', 'exact bid -> bonus', { t: '2 (tricks) + 10 (exact bid) = 12', color: C.green, font: 'Helvetica-Bold' }],
['2', '3', 'bid over by one', { t: '3 (tricks) + 0 (missed bid) = 3', color: C.red, font: 'Helvetica-Bold' }],
['0', '0', 'exact bid -> bonus', { t: '0 (tricks) + 10 (exact bid) = 10', color: C.green, font: 'Helvetica-Bold' }],
['1', '0', 'bid under by one', { t: '0 (tricks) + 0 (missed bid) = 0', color: C.red, font: 'Helvetica-Bold' }],
], [44, 70, 120, 226], { fontSize: 9 });
body('**Running total:** keep a cumulative score for every player. After scoring a round, add the points to the player\u2019s previous total and write the **new total** in that round\u2019s cell. The bottom row (**Position**) shows the final standing. Highest score after Round 10 wins.');
// ---- 6. Worked Examples ----
doc.addPage();
h1('6. Worked Examples (3 Players)');
hr();
body('These are the **first three rounds of one game** (1, 2 and 3 cards), played out in full to show *why* each player plays each card. Players are **Anna**, **Ben** and **Carla**, seated in that clockwise order. Each card is shown as a small TROX card in its suit colour.');
// === Example A: 2-card round (vormals B) ===
const exA_hands = [
['Player', 'Hand'],
[{ t: 'Anna', font: 'Helvetica-Bold' }, { chips: ['R9', 'Y3'] }],
[{ t: 'Ben', font: 'Helvetica-Bold' }, { chips: ['Y17', 'Y8'] }],
[{ t: 'Carla', font: 'Helvetica-Bold' }, { chips: ['R15', 'B2'] }],
];
const exA_tricks = [
['Trick', 'Anna', 'Ben', 'Carla', 'Winner / why'],
[{ t: '1 (lead Yellow)' }, { chips: ['Y3'], align: 'center' }, { chips: ['Y17'], align: 'center' }, { chips: ['R15'], align: 'center' }, { t: 'Carla \u2013 trumps with Red 15. Ben bid 0, so he dumps his high Yellow 17 now while Carla is taking the trick anyway.', font: 'Helvetica' }],
[{ t: '2 (lead Black)' }, { chips: ['R9'], align: 'center' }, { chips: ['Y8'], align: 'center' }, { chips: ['B2'], align: 'center' }, { t: 'Anna \u2013 no Black, trumps Red 9; beats Carla\u2019s led Black 2. Ben sloughs his last card.', font: 'Helvetica' }],
];
const exA_result = [
['Player', 'Bid', 'Won', 'Points'],
['Anna', { t: '1', align: 'center' }, { t: '1', align: 'center' }, { t: '11 (exact bid -> +10)', color: C.green, font: 'Helvetica-Bold' }],
['Ben', { t: '0', align: 'center' }, { t: '0', align: 'center' }, { t: '10 (exact bid -> +10)', color: C.green, font: 'Helvetica-Bold' }],
['Carla', { t: '1', align: 'center' }, { t: '1', align: 'center' }, { t: '11 (exact bid -> +10)', color: C.green, font: 'Helvetica-Bold' }],
];
// === Example B: 3-card round (vormals A), Blätter rotiert ===
// Anna bot in Runde A zuerst (Nachteil) -> in Runde B ist Ben an der Reihe,
// bietet zuerst und führt den ersten Stich an. Die Blätter wandern mit
// (Annas -> Ben, Bens -> Carla, Carlas -> Anna), die Stichlogik bleibt gleich.
const exB_hands = [
['Player', 'Hand'],
[{ t: 'Ben', font: 'Helvetica-Bold' }, { chips: ['R11', 'Y18', 'B4'] }],
[{ t: 'Carla', font: 'Helvetica-Bold' }, { chips: ['G20', 'Y5', 'R2'] }],
[{ t: 'Anna', font: 'Helvetica-Bold' }, { chips: ['Y14', 'B19', 'G7'] }],
];
const exB_tricks = [
['Trick', 'Ben', 'Carla', 'Anna', 'Winner / why'],
[{ t: '1 (lead Yellow)' }, { chips: ['Y18'], align: 'center' }, { chips: ['Y5'], align: 'center' }, { chips: ['Y14'], align: 'center' }, { t: 'Ben \u2013 highest Yellow', font: 'Helvetica' }],
[{ t: '2 (lead Red)' }, { chips: ['R11'], align: 'center' }, { chips: ['R2'], align: 'center' }, { chips: ['B19'], align: 'center' }, { t: 'Ben \u2013 trump; Anna has no Red, sloughs Black 19', font: 'Helvetica' }],
[{ t: '3 (lead Black)' }, { chips: ['B4'], align: 'center' }, { chips: ['G20'], align: 'center' }, { chips: ['G7'], align: 'center' }, { t: 'Ben \u2013 only Black counts; Green 20 is worth 0', font: 'Helvetica' }],
];
const exB_result = [
['Player', 'Bid', 'Won', 'Points'],
['Ben', { t: '2', align: 'center' }, { t: '3', align: 'center' }, { t: '3 (missed bid \u2013 no bonus)', color: C.red }],
['Carla', { t: '1', align: 'center' }, { t: '0', align: 'center' }, '0 (missed bid)'],
['Anna', { t: '0', align: 'center' }, { t: '0', align: 'center' }, { t: '10 (exact bid -> +10)', color: C.green, font: 'Helvetica-Bold' }],
];
// === Example C: 1-card round (rotation continues) ===
// Ben gab -> Carla bietet zuerst & führt an. Bietreihenfolge: Carla, Anna, Ben.
const exC_hands = [
['Player', 'Hand'],
[{ t: 'Carla', font: 'Helvetica-Bold' }, { chips: ['G12'] }],
[{ t: 'Anna', font: 'Helvetica-Bold' }, { chips: ['R3'] }],
[{ t: 'Ben', font: 'Helvetica-Bold' }, { chips: ['Y19'] }],
];
const exC_tricks = [
['Trick', 'Carla', 'Anna', 'Ben', 'Winner / why'],
[{ t: '1 (lead Green)' }, { chips: ['G12'], align: 'center' }, { chips: ['R3'], align: 'center' }, { chips: ['Y19'], align: 'center' }, { t: 'Anna \u2013 her tiny Red 3 is a trump and beats everything. Ben\u2019s high Yellow 19 is worthless: it is neither trump nor the led suit.', font: 'Helvetica' }],
];
const exC_result = [
['Player', 'Bid', 'Won', 'Points'],
['Carla', { t: '0', align: 'center' }, { t: '0', align: 'center' }, { t: '10 (exact bid -> +10)', color: C.green, font: 'Helvetica-Bold' }],
['Anna', { t: '1', align: 'center' }, { t: '1', align: 'center' }, { t: '11 (exact bid -> +10)', color: C.green, font: 'Helvetica-Bold' }],
['Ben', { t: '0', align: 'center' }, { t: '0', align: 'center' }, { t: '10 (exact bid -> +10)', color: C.green, font: 'Helvetica-Bold' }],
];
// Alle Chips automatisch vorab rendern (kein Vergessen mehr möglich)
await preRenderChips(collectChipCodes(exA_hands, exA_tricks, exB_hands, exB_tricks, exC_hands, exC_tricks));
// --- Beispiel 1: 1-Karten-Runde (Daten in exC_*) ---
h2('Example A \u2014 Round 1: a 1-card round (the smallest trump still rules)');
body('This is the **first round** of the game. Ben dealt, so **Carla bids first** (the player to the dealer\u2019s left) and also leads the first \u2013 and only \u2013 trick. With a single card each, every player simply bids 0 or 1.');
table(exC_hands, [90, 330]);
body('**Bids (clockwise from Carla):** Carla bids **0**, Anna bids **1**, Ben bids **0**. Anna holds the only trump, so she confidently expects the trick.');
table(exC_tricks, [82, 56, 56, 56, 175], { fontSize: 9 });
table(exC_result, [90, 55, 55, 230], { fontSize: 9.5 });
note('**Lesson:** Trump always wins \u2013 size does not matter between suits. Anna\u2019s Red 3 beats Ben\u2019s Yellow 19 and Carla\u2019s Green 12, because only Red is trump and the others cannot follow it. Holding any Red card in a 1-card round is almost a guaranteed trick.');
// --- Beispiel 2: 2-Karten-Runde (Daten in exA_*) ---
doc.addPage();
h2('Example B \u2014 Round 2: a 2-card round (using trump to control tricks)');
body('On to **Round 2**, now with two cards each. The deal rotates one seat: Carla dealt this round, so **Anna bids first** and also leads the first trick. (These are the same three players continuing the same game.)');
table(exA_hands, [90, 330]);
body('**Bids (clockwise from Anna):** Anna **1**, Ben **0**, Carla **1**. Both Anna and Carla hold a trump, so each expects one trick.');
table(exA_tricks, [70, 50, 50, 50, 205], { fontSize: 9 });
table(exA_result, [90, 55, 55, 230], { fontSize: 9.5 });
note('**Lesson:** Ben was squeezed between two trumps and correctly read that he could not win a trick, so he bid 0 and threw away his dangerous high Yellow 17 first. Note that here the bids summed to 2 with exactly 2 tricks available \u2013 so it was possible (though not guaranteed) for everyone to hit. That tension is the heart of TROX.');
// --- Beispiel 3: 3-Karten-Runde (Daten in exB_*) ---
doc.addPage();
h2('Example C \u2014 Round 3: a 3-card round (the danger of overbidding)');
body('**Round 3**, three cards each. The deal rotates again: Anna dealt, so **Ben bids first** and leads the first trick. The first-bidder duty has now passed Carla -> Anna -> Ben across the three rounds.');
table(exB_hands, [90, 330]);
body('**Bids (clockwise from Ben):** Ben bids **2**, Carla bids **1**, Anna bids **0**.');
small('Anna\u2019s hand has no trump and only middling cards, so she sensibly aims to win nothing.');
table(exB_tricks, [70, 50, 50, 50, 205], { fontSize: 9 });
table(exB_result, [90, 55, 55, 230], { fontSize: 9.5 });
note('**Lesson:** Ben had the strongest hand and won all three tricks \u2013 but because he bid only 2, he scored just 3 points. Anna, with the weakest hand, scored the most by correctly predicting 0. In TROX, **accuracy beats raw strength.**');
// ---- 7. Strategy ----
doc.addPage();
h1('7. Strategy Tips');
hr();
bullet('Count the trumps', 'There are 20 Red cards. High Reds (16\u201320) are near-locks for a trick; low Reds may still be overtrumped. Track which Reds have been played.');
bullet('A high non-trump is fragile', 'Yellow 20 looks powerful, but it wins only if Yellow is led and nobody is void. Against players who are short in Yellow, it can be trumped away.');
bullet('Bidding 0 is a weapon', 'With no trump and only low cards, bid 0 and dump your cards safely. The +10 is identical to winning a 10-trick hand.');
bullet('Leading matters', 'The trick winner leads next \u2013 so winning early lets you steer suits. If you need to stop winning, lead a suit you think opponents can beat.');
bullet('Watch the bid total', 'Add up everyone\u2019s bids. If the total is below the number of tricks, someone *must* take an extra trick \u2013 figure out who is forced to.');
bullet('Late rounds shrink', 'When hands drop back to 1\u20132 cards, a single trump or void decides everything. Bid conservatively when you have no control.');
// ---- 8. Variant ----
h1('8. Optional Variant \u2014 "Exact Tension"');
body('For a sharper game, use the common trick-taking rule that **the bids may not add up to the exact number of tricks in the round.** The last player to bid (the dealer) is forced to bid a number that makes the total higher or lower than the available tricks. This guarantees that **at least one player will miss** every round, raising the pressure. This rule is optional \u2013 agree on it before the game starts.');
// ---- 9. Scorepad / QR ----
h1('9. Get Your Scorepad');
ensureSpace(150);
{
const boxY = doc.y + 4;
const qrSize = 96;
const boxH = qrSize + 28;
const boxX = ML;
const boxW = CONTENT_W;
// dezenter Rahmen
doc.save().roundedRect(boxX, boxY, boxW, boxH, 10).lineWidth(1).strokeColor(C.goldLt).stroke().restore();
// QR links
if (QR_BUFFER) doc.image(QR_BUFFER, boxX + 16, boxY + 14, { width: qrSize, height: qrSize });
// Text rechts daneben
const tx = boxX + 16 + qrSize + 18;
const tw = boxW - (16 + qrSize + 18) - 16;
let ty = boxY + 20;
doc.font('Helvetica-Bold').fontSize(12).fillColor(C.gold).text('Need a scorepad?', tx, ty, { width: tw });
ty = doc.y + 2;
doc.font('Helvetica').fontSize(9.5).fillColor(C.bodyTx)
.text('Scan the QR code to open the TROX scorepad page. Choose how many players you have, and the site prints the best-fitting PDF scorepad for your game.', tx, ty, { width: tw, lineGap: 2 });
doc.font('Helvetica-Oblique').fontSize(8).fillColor(C.grey)
.text(SCOREPAD_URL, tx, doc.y + 2, { width: tw });
doc.y = boxY + boxH + 8;
doc.x = ML;
}
// ---- Quick Reference ----
h2('Quick Reference');
table([
[{ t: 'Players', color: C.gold, font: 'Helvetica-Bold' }, '2\u20136 (best 3\u20134)'],
[{ t: 'Deck', color: C.gold, font: 'Helvetica-Bold' }, '80 cards \u2013 4 suits (R/Y/G/B), 1\u201320'],
[{ t: 'Trump', color: C.gold, font: 'Helvetica-Bold' }, 'Red (R) \u2013 always'],
[{ t: 'Rounds', color: C.gold, font: 'Helvetica-Bold' }, '10 \u2013 cards: 1\u00b72\u00b73\u00b74\u00b75\u00b75\u00b74\u00b73\u00b72\u00b71'],
[{ t: 'Per trick', color: C.gold, font: 'Helvetica-Bold' }, '+1 point'],
[{ t: 'Exact bid', color: C.gold, font: 'Helvetica-Bold' }, '+10 bonus (else 0)'],
[{ t: 'Win', color: C.gold, font: 'Helvetica-Bold' }, 'Highest total after Round 10'],
], [130, 300], { header: false, zebra: true });
doc.moveDown(0.4);
hr(C.greyLn, 0.8);
doc.font('Helvetica-Oblique').fontSize(9).fillColor(C.gold)
.text('TROX \u2013 Bid exactly. Win tricks. Rule the table.', ML, doc.y, { width: CONTENT_W, align: 'center' });
doc.end();
console.log('PDF erstellt: TROX_Rules_Extended_EN.pdf');
}
main().catch(err => { console.error(err); process.exit(1); });