No description
  • JavaScript 60.5%
  • Python 21%
  • CSS 10.3%
  • HTML 8.2%
Find a file
Jaarl27 e563badac0 Review fixes, seat-order display, split into web/index.html, app.css, app.js
- Round view no longer switches to the live list when the phone wakes up
- Body cap 128 KB so 500 games fit; the app stops at 500 instead of losing games
- Names: 20 characters without half emoji (lone surrogates crashed the DB write)
- normalise() matches clean() for null out/dealer and duplicate knockers
- Server: OverflowError, query string on PUT, atomic update with RETURNING,
  refused requests don't use up other rate limits, housekeeping survives errors
- Columns are shown in seating order everywhere (stored order unchanged)
- Static files from web/ with ETag; CSP script-src 'self' instead of hashes
- favicon.svg and apple-touch-icon.png as files

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:10:22 +01:00
web Review fixes, seat-order display, split into web/index.html, app.css, app.js 2026-11-05 17:10:22 +01:00
.gitattributes Review fixes, seat-order display, split into web/index.html, app.css, app.js 2026-11-05 17:10:22 +01:00
.gitignore Review fixes, seat-order display, split into web/index.html, app.css, app.js 2026-11-05 17:10:22 +01:00
compose.yaml Review fixes, seat-order display, split into web/index.html, app.css, app.js 2026-11-05 17:10:22 +01:00
make_favicon.py Review fixes, seat-order display, split into web/index.html, app.css, app.js 2026-11-05 17:10:22 +01:00
README.md Review fixes, seat-order display, split into web/index.html, app.css, app.js 2026-11-05 17:10:22 +01:00
server.py Review fixes, seat-order display, split into web/index.html, app.css, app.js 2026-11-05 17:10:22 +01:00

Schafkopf-Rechner

https://schafkopf.rollfisch.de: a mobile score calculator for Schafkopf, with a beer/joint tally and a "Rausch-Rating".

  • web/: the app. index.html (markup), app.css, app.js (all logic, no dependencies), the icons. It works offline and stores the list in the browser's localStorage.
  • server.py: Python stdlib only. It serves the files in web/ and a small JSON API backed by SQLite (data/schafkopf.db) for live share links.
  • make_favicon.py: builds web/favicon.svg and web/apple-touch-icon.png from the suit icons.

The first "Teilen" uploads the list and returns a link like /#k7f3q2. From then on, every change is pushed (the dot on the button: green = synced, red = offline, it retries). Viewers poll every 4 s. Only the device that shared the list can change it: it keeps a secret key in localStorage. "Neue Liste" asks "Gleiche Spieler" or "Neue Spieler" (clears names and seat order, then walks through the names). By default it keeps the link, so viewers at the table keep watching. Ticking "Neuer Teilen-Link" pushes the old list one last time and drops the key; the old link keeps showing that list, and the next "Teilen" creates a new one.

Frühere Runden (round history)

A finished list with at least one game becomes a round when "Neue Liste" is used, when fewer players continue (⚙ → Spieler am Tisch), or when another list or round is taken over ("übernehmen").

  • Shared list: the round goes to the server under the same link. Until the upload works (offline at the table) it waits in localStorage (schafkopf-pending-v1) and is shown as "noch nicht hochgeladen". push() uploads waiting rounds first. If the server refuses one (rate limit), it still PUTs the list so viewers stay current, and tries the round again in 15 s.
  • Not shared: the last 20 rounds stay on this device (schafkopf-rounds-v1). Dropping a link moves waiting rounds there too.
  • Everyone with the link sees the "Frühere Runden" card. Tapping a round opens it read-only in the live view (stats and downloads work). "Als aktuelle Liste übernehmen" continues it; the current list is saved as a round first.

Names and player icons

Every column has a fixed icon: Eichel, Laub, Herz, Schelle, and the Weizen glass for the 5th player (PLAYER_ICON). Without a name only the icon is shown; plain-text spots (toasts, the dealer select) use the suit name instead. Tap a name or icon on the top tiles to rename that player.

API: POST /api/lists → {id, key} · PUT /api/lists/<id> (header X-Key) · GET /api/lists/<id> (ETag/304, includes rounds = count). Rounds: POST /api/lists/<id>/rounds (X-Key, body {done, next}): stores done as the next round if it has games and isn't a resend of the last round, and replaces the list with next, in one transaction. GET /api/lists/<id>/rounds → summaries (names, totals, game count), GET /api/lists/<id>/rounds/<n> → the full list. IDs have 8 characters; older 6-character IDs are still accepted.

Abuse protection (the API is public)

  • Only list data is stored: normalise() in server.py keeps names (20 characters), the known fields and numbers, and drops everything else. This keeps the API from being used as free anonymous storage. clean() in app.js does the same on the client. Keep the two in sync.
  • Caps: 128 KB per list (enough for 500 games; 256 KB for a round upload), 500 games (the app asks for a "Neue Liste" when a list is full), 10000 lists, 200 rounds per list (oldest dropped), 1 GB of rounds in total (oldest dropped, checked hourly). Lists are deleted after 180 days without changes, lists without games and rounds after 7 days. Rounds go with their list.
  • Rate limits per IP (IPv6 per /64): 20 creates, 1500 writes, 30 round uploads and 20000 reads per hour (reads are high because every phone at the table polls every 4 s, often behind one NAT address), plus 300 creates and 300 round uploads per hour for everyone together. A refused request doesn't count against the other limits. At most 50000 rate-limit entries are kept; beyond that, new addresses are refused until old entries expire (keeps memory bounded against address floods).
  • Resource caps, so a flood only hits this app, never the shared host: at most 32 requests handled at once (more are dropped), at most 4 uploads in flight per client (slow bodies can't take every thread), 10 s timeout. The container gets 128 MB, 64 pids and 0.5 CPU, runs read-only as nobody with no capabilities and no-new-privileges. Logs are limited to 3 × 1 MB; hung-up clients are not logged.
  • Client IP: Caddy (no CDN in front) replaces any client-sent X-Forwarded-For, so the last entry is the real IP.
  • Old data: housekeeping checkpoints the WAL hourly (wal_checkpoint(TRUNCATE)), so overwritten and deleted lists don't linger in schafkopf.db-wal. An error in housekeeping is logged and retried the next hour.
  • Browser side: CSP allows scripts only from app.js (no inline scripts), plus escaping and HSTS.
  • Files: only the fixed list in STATIC (server.py) is served, nothing else from the disk.
  • Logging: docker logs schafkopf shows rejected requests (400/403/429/503) with the client IP.

Seat order

The stored column order (players, and the amounts in every game) stays fixed. seats holds the actual seating order: the deal moves along it, and every view shows the columns in that order (cols() in app.js): name tiles, entry buttons, game history, tally, statistics, picture and CSV. To swap two players' seats: ⚙ → Sitzordnung, tap two players. Each game stores its dealer, so "Rückgängig" restores the right dealer even after a seat swap.

3 / 4 / 5 players (⚙ → Spieler am Tisch)

  • 3: no Sauspiel, only Solo, Wenz and Ramsch. The soloist gets or pays the tariff from/to both opponents; in Ramsch the loser pays both. (In code: opp in calc() = number of opponents.)
  • 5: the dealer sits out: they pay nothing and get nothing (out per game).
  • Players can be added at any time (new columns get 0 in old games).
  • Fewer players: the dialog asks who leaves. With games in the list, they are saved as a round and a new list starts with the remaining players (tally back to 0); without games only the columns go. The column icons follow the column, so a remaining player can get a different icon.
  • Toasts move into an open dialog, so messages aren't hidden behind it.

Downloads (download icon in the header)

  • Bild (PNG): drawn on a canvas, light theme, ranking with tags plus the game history (the last 200 games). On phones it opens the share sheet (WhatsApp, "Bild sichern"); elsewhere it downloads.
  • Sicherung (.json): {app: 'schafkopf', saved, data}. "Sicherung laden" reads it back through clean() (max 1 MB) and replaces the current list.
  • Tabelle (.csv): semicolon-separated with decimal commas and a BOM, for German Excel. Text cells that start with = + - @ get a leading ' so names from shared lists can't run as formulas. Works in the live view too (it saves the viewed list); loading is only offered for your own list.

Rules

  • Sauspiel: each loser pays the Grundtarif (default 0.10) to one winner.
  • Solo / Wenz: the soloist gets or pays the Solotarif (default 0.50) from/to each of the three others.
  • Each of Schneider, Schwarz (includes Schneider) and each Laufender adds 1 × Grundtarif. Laufende count from 3, or from 2 in a Wenz.
  • Each of Klopfen/Legen, Kontra, Re and Tout (Solo/Wenz only) doubles the whole amount.
  • Ramsch: the loser pays the Grundtarif to each player. Jungfrau and Klopfen double it.

Varianten (⚙ → Varianten, per list in variants)

Switched-on variants get a button after the four basic types. Rules live in RULE in app.js (tariff, Laufende from/max, team game, Tout allowed); keep TYPES/VARIANTS in server.py in sync.

  • Hochzeit: team of two like a Sauspiel (first tap = whoever offered it), Grundtarif. Not offered with 3 players.
  • Geier: Solotarif, Laufende from 2 (max 4), like the Wenz.
  • Farbwenz / Farbgeier: Solotarif, Laufende from 3 (max 11).
  • Bettel: Solotarif, no Schneider or Laufende. "Offen" (Bettel Brett, stored as tout) doubles.
  • Strafe: the selected player pays pen to each other player; pen null (the default, field left empty) means the Grundtarif, so with 4 players the others get 0.10 each and the penalised player pays 0.30. It isn't a game: the dealer doesn't move, and the statistics only count its money (Bilanz, plus a "Strafen" line).
  • Statistics group the types into families: Sauspiel (+ Hochzeit), Wenz (+ Geier, Farbwenz, Farbgeier), Solo, Ramsch, Bettel (slot 5 color). In the Ansagen table Bettel counts under Solo.

Entry details that feed the statistics

  • Sauspiel: the first tapped player is the Ansager (sel[0]), the second the Mitspieler (lighter highlight). A third tap replaces the Mitspieler.
  • Klopfen/Legen: tap who knocked (kb, list of player indices); klopf = kb.length doubles as before. Games before Nov 4 2026 only have the count, so they count as "nobody knocked" in the stats.
  • Ramsch Durchmarsch (durch): the selected player took every trick and is paid like a won Solo (Solotarif × 2^Klopfen from each). It excludes Jungfrau.

Statistik (collapsible card, rendered only while open)

  • Bilanz: total, average per game played (games sat out don't count), win rate, plus a ring chart of game types. Colors are the dataviz reference palette's categorical slots 1–4, with separate dark-mode steps.
  • Ansagen: share of own announcements among non-Ramsch games played, and won/played for Sauspiel, Solo and Wenz.
  • Spieler gegen Spieler: pick two players. Mirrored bars compare win rate, Solo/Wenz rate, announcement rate, "schneiderfrei" (1 − games lost with Schneider / non-Ramsch games) and knock rate. Below that: the head-to-head record (one won while the other lost).
  • Rekorde: most expensive game (sum of all winnings), biggest win and loss, longest winning and losing streaks, most Laufende.
  • Ramsch: lost/played, of which with Jungfrau, Durchmarsch, Ramsch balance.

Rausch-Rating

score = (perf + 0.75 · load + 1) / 2.75 · 100

  • perf = money relative to the table's biggest absolute total (−1…1).
  • load = (beer + 1.5 · joints) relative to the table's maximum (0…1).

Tags:

  • 👑 Bierkönig: most beers.
  • Laub icon, Leaflover: most joints. 🥦 BroccoliBrudi instead, if that person is also in the plus.
  • 🏆 Rauschmeister: best score among players who consumed something and are in the plus.
  • Schelle icon, MussFreitagArbeiten: consumed nothing and is in the minus.

The suit icons are the Bavarian pips from Wikimedia Commons (Bay_eichel.svg, Bay_gras.svg, Bay_herz.svg, Bay_schellen.svg by Infanf, public domain), reduced to their path/circle shapes and centred in a square viewBox (suit() in ICON). The Weizen glass and the gear/share icons are drawn by hand. There are no Unicode glyphs for German suits. The favicon (the four suits 2×2 on the app blue, web/favicon.svg) and the iPhone home-screen icon (web/apple-touch-icon.png, 180×180, square because iOS rounds the corners itself) are built from ICON by make_favicon.py. Run it again after changing a suit icon; the PNG needs Edge or Chrome for rendering.

Deploy

Docker Compose (compose.yaml) on an external docker network proxy (no host port), behind a Caddy reverse proxy on the same network. data/ must be owned by 65534 (the container runs as nobody). Caddy block:

schafkopf.rollfisch.de {
    encode gzip
    reverse_proxy schafkopf:80
}

Update: copy server.py and the web/ folder next to compose.yaml. Files in web/ are reread when they change, so they need no restart; server.py needs docker compose restart. Page, script and style are sent with an ETag and no-cache, so phones load a new version right away and never mix old and new files. Back up: data/schafkopf.db (stop the container or use sqlite3 .backup; the DB uses WAL mode).