feat(catalog): add starting-stock tool, vocabulary proposal and CI checks

Deno script validates vocabulary.csv and startbestand.csv and generates the idempotent SQL, key list and checks.sql. startbestand.csv stays header-only; vocabulary.csv is a proposal pending approval (C0). seed.sql no longer inserts genres and mechanics.

Refs #2

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-03 15:38:28 +01:00
parent 3c762003b2
commit 9dbafbcad7
13 changed files with 1239 additions and 11 deletions

View file

@ -9,6 +9,8 @@ on:
env: env:
SUPABASE_CLI_VERSION: 3.0.0 SUPABASE_CLI_VERSION: 3.0.0
# Deno für das Startbestand-Werkzeug (supabase/catalog/, KAT-05); lokal getestet mit 2.9.8.
DENO_VERSION: 2.9.8
# Einzige Stelle für die Flutter-Version (= lokale Version, Dart 3.13.7; muss zu app/pubspec.yaml passen). # Einzige Stelle für die Flutter-Version (= lokale Version, Dart 3.13.7; muss zu app/pubspec.yaml passen).
# Muss ein existierendes Tag in github.com/flutter/flutter sein. Ein Cirrus-Image mit dieser Version gibt es nicht. # Muss ein existierendes Tag in github.com/flutter/flutter sein. Ein Cirrus-Image mit dieser Version gibt es nicht.
FLUTTER_VERSION: 3.47.9 FLUTTER_VERSION: 3.47.9
@ -147,6 +149,36 @@ jobs:
if [ -n "$changed" ]; then echo "Geänderte bestehende Migrationen: $changed"; exit 1; fi if [ -n "$changed" ]; then echo "Geänderte bestehende Migrationen: $changed"; exit 1; fi
fi fi
# Startbestand-Werkzeug (KAT-05, Spec 2 D1): Deno-Tests, erzeugte Dateien aktuell, Schlüsselliste wächst nur.
- name: Deno installieren
run: |
arch=$(uname -m); [ "$arch" = "aarch64" ] && target=aarch64-unknown-linux-gnu || target=x86_64-unknown-linux-gnu
curl -fsSL "https://github.com/denoland/deno/releases/download/v${DENO_VERSION}/deno-${target}.zip" -o /tmp/deno.zip
unzip -q -o /tmp/deno.zip -d /usr/local/bin
deno --version
- name: Startbestand-Werkzeug testen
run: deno test --allow-read=supabase supabase/catalog/
- name: Erzeugte Katalogdateien aktuell?
run: |
git config --global --add safe.directory "$GITHUB_WORKSPACE"
deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts
if ! git diff --exit-code -- supabase/catalog supabase/tests/support; then
echo "::error::Erzeugte Dateien sind nicht aktuell. Lokal 'deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts' ausführen und committen."
exit 1
fi
untracked=$(git ls-files --others --exclude-standard -- supabase/catalog supabase/tests/support)
if [ -n "$untracked" ]; then echo "Nicht eingecheckte erzeugte Dateien:"; echo "$untracked"; exit 1; fi
- name: startbestand-keys.csv wächst nur
if: github.event_name == 'pull_request'
run: |
# Jede Zeile der Schlüsselliste aus dem Basis-Branch muss unverändert erhalten bleiben (Schlüssel sind unveränderlich).
git fetch origin "${{ github.base_ref }}" --depth=1
if git cat-file -e "origin/${{ github.base_ref }}:supabase/catalog/startbestand-keys.csv" 2>/dev/null; then
git show "origin/${{ github.base_ref }}:supabase/catalog/startbestand-keys.csv" > /tmp/keys-base.csv
missing=$(grep -vxFf supabase/catalog/startbestand-keys.csv /tmp/keys-base.csv || true)
if [ -n "$missing" ]; then echo "::error::Zeilen aus startbestand-keys.csv fehlen oder wurden geändert:"; echo "$missing"; exit 1; fi
fi
# RLS-/pgTAP-Tests: braucht einen Runner mit Docker-Zugriff (Label "docker-host"). # RLS-/pgTAP-Tests: braucht einen Runner mit Docker-Zugriff (Label "docker-host").
# Der Job-Container hat kein Docker-CLI und keinen Socket (Isolation, Repo ist öffentlich). Stattdessen # Der Job-Container hat kein Docker-CLI und keinen Socket (Isolation, Repo ist öffentlich). Stattdessen
# wird ein gepinntes Docker-CLI installiert und der dind-Daemon des Runners per TCP angesprochen # wird ein gepinntes Docker-CLI installiert und der dind-Daemon des Runners per TCP angesprochen

View file

@ -0,0 +1,670 @@
// Startbestand-Werkzeug (KAT-05, Spec docs/specs/2-katalog-daten.md, Abschnitt 4.3).
//
// Liest die von Hand gepflegten Dateien
// supabase/catalog/vocabulary.csv (Genres und Mechaniken, de/en)
// supabase/catalog/startbestand.csv (Spiele; Format siehe docs/katalog-pflege.md)
// prüft sie (SB2, SB4-SB7) und erzeugt daraus
// supabase/catalog/vocabulary.sql, startbestand.sql, startbestand-keys.csv, checks.sql
// supabase/tests/support/{vocabulary,startbestand,checks}_fixture.inc (nur für pgTAP, aus catalog/testdata/,
// siehe 06_catalog_foundation.test.sql)
// Die erzeugten Dateien nie von Hand bearbeiten. Keine Abhängigkeiten außer der Deno-Standardumgebung.
//
// Aufruf (im Repo-Root):
// deno run --allow-read=supabase --allow-write=supabase supabase/catalog/build_startbestand.ts
//
// Das Skript erfindet keine Daten. Es erzeugt nie Cover-, Quellen- oder BGG-Angaben (SB6).
export const NAMESPACE = "6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4";
export const MIN_VISIBLE = 180;
export const GENERATED_HEADER =
"-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten.\n";
// ---------------------------------------------------------------------------------------------
// Fehler
// ---------------------------------------------------------------------------------------------
export class BuildError extends Error {
constructor(public readonly problems: string[]) {
super(problems.join("\n"));
this.name = "BuildError";
}
}
// ---------------------------------------------------------------------------------------------
// UUID v5 (RFC 4122), gleiches Ergebnis wie uuid_generate_v5() in PostgreSQL
// ---------------------------------------------------------------------------------------------
function uuidToBytes(uuid: string): Uint8Array {
const hex = uuid.replaceAll("-", "");
const out = new Uint8Array(16);
for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
export async function uuidV5(name: string, namespace: string = NAMESPACE): Promise<string> {
const nameBytes = new TextEncoder().encode(name);
const data = new Uint8Array(16 + nameBytes.length);
data.set(uuidToBytes(namespace), 0);
data.set(nameBytes, 16);
const hash = new Uint8Array(await crypto.subtle.digest("SHA-1", data)).slice(0, 16);
hash[6] = (hash[6] & 0x0f) | 0x50;
hash[8] = (hash[8] & 0x3f) | 0x80;
const hex = Array.from(hash, (b) => b.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
// ---------------------------------------------------------------------------------------------
// CSV (RFC 4180: Anführungszeichen, "" als Escape, Zeilenumbrüche in Feldern, optionales BOM)
// ---------------------------------------------------------------------------------------------
export interface CsvRow {
line: number;
cells: Record<string, string>;
}
export interface Csv {
header: string[];
rows: CsvRow[];
}
export function parseCsv(text: string, file: string): Csv {
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
const records: { line: number; fields: string[] }[] = [];
let fields: string[] = [];
let field = "";
let inQuotes = false;
let line = 1;
let recordLine = 1;
let touched = false; // Satz hat Inhalt (auch ein leeres Feld nach Komma)
const problems: string[] = [];
const endField = () => {
fields.push(field);
field = "";
};
const endRecord = () => {
endField();
if (touched || fields.length > 1 || fields[0] !== "") {
records.push({ line: recordLine, fields });
}
fields = [];
touched = false;
};
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') {
field += '"';
i++;
} else inQuotes = false;
} else {
if (c === "\n") line++;
field += c;
}
continue;
}
if (c === '"' && field === "") {
inQuotes = true;
touched = true;
} else if (c === ",") {
touched = true;
endField();
} else if (c === "\r") {
// wird mit dem folgenden \n behandelt; alleinstehendes \r zählt als Zeilenende
if (text[i + 1] !== "\n") {
endRecord();
line++;
recordLine = line;
}
} else if (c === "\n") {
endRecord();
line++;
recordLine = line;
} else {
field += c;
touched = true;
}
}
if (inQuotes) problems.push(`${file}:${recordLine}: Anführungszeichen nicht geschlossen`);
endRecord();
if (problems.length) throw new BuildError(problems);
if (records.length === 0) throw new BuildError([`${file}: Kopfzeile fehlt`]);
const header = records[0].fields.map((h) => h.trim());
const rows: CsvRow[] = [];
for (const r of records.slice(1)) {
if (r.fields.length !== header.length) {
problems.push(
`${file}:${r.line}: ${r.fields.length} Spalten statt ${header.length} (Komma im Text? Feld in Anführungszeichen setzen)`,
);
continue;
}
const cells: Record<string, string> = {};
header.forEach((h, idx) => (cells[h] = r.fields[idx]));
rows.push({ line: r.line, cells });
}
if (problems.length) throw new BuildError(problems);
return { header, rows };
}
function checkHeader(csv: Csv, file: string, required: string[], optional: string[]): string[] {
const problems: string[] = [];
const allowed = new Set([...required, ...optional]);
for (const col of required) {
if (!csv.header.includes(col)) problems.push(`${file}:1: Spalte "${col}" fehlt`);
}
for (const col of csv.header) {
if (!allowed.has(col)) {
// SB6: Es gibt bewusst keine Spalte für Cover, Quelle oder BGG-Kennung.
problems.push(`${file}:1: unbekannte Spalte "${col}" (erlaubt: ${[...allowed].join(", ")})`);
}
}
const seen = new Set<string>();
for (const col of csv.header) {
if (seen.has(col)) problems.push(`${file}:1: Spalte "${col}" doppelt`);
seen.add(col);
}
return problems;
}
// ---------------------------------------------------------------------------------------------
// Vokabular
// ---------------------------------------------------------------------------------------------
export type VocabKind = "genre" | "mechanic";
export interface VocabEntry {
kind: VocabKind;
vocab_key: string;
name_de: string;
name_en: string;
id: string;
}
export async function parseVocabulary(text: string, file = "vocabulary.csv"): Promise<VocabEntry[]> {
const csv = parseCsv(text, file);
const problems = checkHeader(csv, file, ["kind", "vocab_key", "name_de", "name_en"], []);
if (problems.length) throw new BuildError(problems);
const entries: VocabEntry[] = [];
const seenKey = new Set<string>();
const seenDe = new Set<string>();
const seenEn = new Set<string>();
for (const { line, cells } of csv.rows) {
const where = `${file}:${line}`;
const kind = cells.kind.trim();
const vocab_key = cells.vocab_key.trim();
const name_de = cells.name_de.trim();
const name_en = cells.name_en.trim();
if (kind !== "genre" && kind !== "mechanic") {
problems.push(`${where}: kind muss "genre" oder "mechanic" sein, ist "${kind}"`);
continue;
}
if (!/^[a-z0-9-]{2,40}$/.test(vocab_key)) {
problems.push(`${where}: vocab_key "${vocab_key}" ungültig (a-z, 0-9, Bindestrich, 2-40 Zeichen)`);
}
for (const [label, v] of [["name_de", name_de], ["name_en", name_en]] as const) {
if (v.length < 1 || v.length > 100) problems.push(`${where}: ${label} muss 1-100 Zeichen haben`);
}
const k = `${kind}:${vocab_key}`;
if (seenKey.has(k)) problems.push(`${where}: vocab_key "${vocab_key}" doppelt (${kind})`);
seenKey.add(k);
const de = `${kind}:${name_de.toLowerCase()}`;
if (seenDe.has(de)) problems.push(`${where}: name_de "${name_de}" doppelt (${kind})`);
seenDe.add(de);
const en = `${kind}:${name_en.toLowerCase()}`;
if (seenEn.has(en)) problems.push(`${where}: name_en "${name_en}" doppelt (${kind})`);
seenEn.add(en);
entries.push({ kind, vocab_key, name_de, name_en, id: await uuidV5(`${kind}:${vocab_key}`) });
}
if (problems.length) throw new BuildError(problems);
return entries;
}
// ---------------------------------------------------------------------------------------------
// Schlüsselliste (append-only)
// ---------------------------------------------------------------------------------------------
export function parseKeys(text: string, file = "startbestand-keys.csv"): Map<string, string> {
const csv = parseCsv(text, file);
const problems = checkHeader(csv, file, ["key", "uuid"], []);
if (problems.length) throw new BuildError(problems);
const keys = new Map<string, string>();
for (const { line, cells } of csv.rows) {
if (keys.has(cells.key)) problems.push(`${file}:${line}: key "${cells.key}" doppelt`);
keys.set(cells.key, cells.uuid);
}
if (problems.length) throw new BuildError(problems);
return keys;
}
export function renderKeys(keys: Map<string, string>): string {
const lines = [...keys.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([k, u]) => `${k},${u}`);
return ["key,uuid", ...lines].join("\n") + "\n";
}
// ---------------------------------------------------------------------------------------------
// Startbestand
// ---------------------------------------------------------------------------------------------
export interface Game {
key: string;
id: string;
title: string;
publishers: string[];
year: number;
min_players: number;
max_players: number;
min_minutes: number;
max_minutes: number;
min_age: number;
genres: string[]; // name_de
mechanics: string[]; // name_de
description: string;
hidden: boolean;
}
const REQUIRED_COLUMNS = [
"key", "title", "publishers", "year", "min_players", "max_players", "min_minutes", "max_minutes",
"min_age", "genres", "mechanics", "description", "description_locale",
];
const OPTIONAL_COLUMNS = ["hidden", "facts_source", "alt_titles", "base_game_key"];
const SMALLINT_MAX = 32767;
function splitList(value: string): string[] {
return value.split(";").map((s) => s.trim()).filter((s) => s !== "");
}
export async function parseStartbestand(
text: string,
vocabulary: VocabEntry[],
previousKeys: Map<string, string>,
file = "startbestand.csv",
): Promise<Game[]> {
const csv = parseCsv(text, file);
const problems = checkHeader(csv, file, REQUIRED_COLUMNS, OPTIONAL_COLUMNS);
if (problems.length) throw new BuildError(problems);
const genreNames = new Set(vocabulary.filter((v) => v.kind === "genre").map((v) => v.name_de));
const mechanicNames = new Set(vocabulary.filter((v) => v.kind === "mechanic").map((v) => v.name_de));
const games: Game[] = [];
const seenKeys = new Set<string>();
const allKeys = new Set(csv.rows.map((r) => r.cells.key.trim()));
for (const { line, cells } of csv.rows) {
const where = `${file}:${line}`;
const key = cells.key.trim();
const before = problems.length;
const fail = (msg: string) => problems.push(`${where}: ${msg}`);
if (!/^[a-z0-9-]{2,60}$/.test(key)) fail(`key "${key}" ungültig (a-z, 0-9, Bindestrich, 2-60 Zeichen)`);
if (seenKeys.has(key)) fail(`key "${key}" doppelt`);
seenKeys.add(key);
const title = cells.title.trim();
if (title.length < 1 || title.length > 200) fail("title muss 1-200 Zeichen haben");
const publishers = splitList(cells.publishers);
if (publishers.length === 0) fail("mindestens ein Verlag nötig (publishers)");
const seenPub = new Set<string>();
for (const p of publishers) {
if (p.length > 200) fail(`Verlag "${p.slice(0, 30)}…" länger als 200 Zeichen`);
if (seenPub.has(p.toLowerCase())) fail(`Verlag "${p}" doppelt`);
seenPub.add(p.toLowerCase());
}
const num = (col: string, min: number, max: number): number => {
const raw = cells[col].trim();
if (raw === "") {
fail(`${col} fehlt (Pflicht-Fakt, SB2)`);
return NaN;
}
if (!/^\d+$/.test(raw)) {
fail(`${col} "${raw}" ist keine ganze Zahl`);
return NaN;
}
const n = Number(raw);
if (n < min || n > max) {
fail(`${col} ${n} außerhalb des erlaubten Bereichs ${min}-${max}`);
return NaN;
}
return n;
};
const year = num("year", 1800, 2100);
const min_players = num("min_players", 1, SMALLINT_MAX);
const max_players = num("max_players", 1, SMALLINT_MAX);
const min_minutes = num("min_minutes", 0, SMALLINT_MAX);
const max_minutes = num("max_minutes", 0, SMALLINT_MAX);
const min_age = num("min_age", 0, SMALLINT_MAX);
if (max_players < min_players) fail(`max_players (${max_players}) kleiner als min_players (${min_players})`);
if (max_minutes < min_minutes) fail(`max_minutes (${max_minutes}) kleiner als min_minutes (${min_minutes})`);
const resolveList = (col: "genres" | "mechanics", known: Set<string>, label: string): string[] => {
const names = splitList(cells[col]);
if (names.length === 0) fail(`mindestens ${label} nötig (${col}, SB2)`);
const seen = new Set<string>();
for (const n of names) {
if (!known.has(n)) fail(`unbekannt in ${col}: "${n}" (nur Begriffe aus vocabulary.csv, name_de, SB5)`);
if (seen.has(n)) fail(`${col}: "${n}" doppelt`);
seen.add(n);
}
return names;
};
const genres = resolveList("genres", genreNames, "ein Genre");
const mechanics = resolveList("mechanics", mechanicNames, "eine Mechanik");
const description = cells.description.trim();
if (description === "") fail("Kurztext fehlt (description, SB4)");
if (description.length > 5000) fail("description länger als 5000 Zeichen");
const locale = cells.description_locale.trim();
if (locale !== "de") fail(`description_locale muss "de" sein, ist "${locale}" (SB4)`);
const hiddenRaw = (cells.hidden ?? "").trim().toLowerCase();
if (hiddenRaw !== "" && hiddenRaw !== "ja") fail(`hidden muss leer oder "ja" sein, ist "${cells.hidden}"`);
const base = (cells.base_game_key ?? "").trim();
if (base !== "") {
if (base === key) fail("base_game_key verweist auf das Spiel selbst");
else if (!allKeys.has(base)) fail(`base_game_key "${base}" hat keinen passenden key (SB7)`);
}
if (problems.length === before) {
games.push({
key, id: await uuidV5(`game:${key}`), title, publishers, year, min_players, max_players,
min_minutes, max_minutes, min_age, genres, mechanics, description, hidden: hiddenRaw === "ja",
});
}
}
// Schlüssel sind unveränderlich (W4): Ein früher vorhandener key darf nicht fehlen.
for (const [key, uuid] of previousKeys) {
if (!allKeys.has(key)) {
problems.push(
`${file}: Schlüssel entfernt oder umbenannt: "${key}" (Spiel nie löschen: Spalte hidden = ja setzen)`,
);
}
const expected = await uuidV5(`game:${key}`);
if (uuid !== expected) {
problems.push(`startbestand-keys.csv: uuid zu "${key}" ist ${uuid}, erwartet ${expected} (Namespace geändert?)`);
}
}
if (problems.length) throw new BuildError(problems);
return games;
}
// ---------------------------------------------------------------------------------------------
// SQL-Erzeugung
// ---------------------------------------------------------------------------------------------
export const q = (s: string): string => `'${s.replaceAll("'", "''")}'`;
const jsonLit = (v: unknown): string => `${q(JSON.stringify(v))}::jsonb`;
// Ein Eintrag pro Zeile, damit die erzeugten Dateien lesbar bleiben.
const jsonLines = (v: unknown[]): string =>
v.length === 0 ? "'[]'::jsonb" : `${q("[\n" + v.map((x) => " " + JSON.stringify(x)).join(",\n") + "\n]")}::jsonb`;
const num = (n: number): string => String(n);
export function generateVocabularySql(vocabulary: VocabEntry[]): string {
const out: string[] = [GENERATED_HEADER];
out.push(
"-- Vokabular (Genres, Mechaniken). Idempotent. Ein im Studio angelegter Begriff mit gleichem Namen",
"-- gewinnt (on conflict do nothing fängt ID- und Namenskonflikte); startbestand.sql löst über name_de auf.",
"",
);
for (const [kind, table] of [["genre", "genres"], ["mechanic", "mechanics"]] as const) {
const rows = vocabulary.filter((v) => v.kind === kind);
if (rows.length === 0) {
out.push(`-- keine ${table} im Vokabular`, "");
continue;
}
out.push(`insert into public.${table} (id, name_de, name_en) values`);
out.push(rows.map((v) => ` (${q(v.id)}, ${q(v.name_de)}, ${q(v.name_en)})`).join(",\n"));
out.push("on conflict do nothing;", "");
}
return out.join("\n");
}
export function generateStartbestandSql(games: Game[]): string {
const out: string[] = [GENERATED_HEADER];
out.push(
"-- Startbestand (KAT-05). Idempotent bis zum Go-live; danach eingefroren (docs/katalog-pflege.md).",
"-- Reihenfolge: Migrationen, vocabulary.sql, dann diese Datei.",
"",
);
if (games.length === 0) {
out.push("-- startbestand.csv enthält keine Spiele.", "");
return out.join("\n");
}
// Wächter: Fehlt ein Begriff (Vokabular nicht eingespielt, im Studio umbenannt), bricht die Datei
// ab, statt still Zuordnungen zu löschen.
const genreNames = [...new Set(games.flatMap((g) => g.genres))].sort();
const mechanicNames = [...new Set(games.flatMap((g) => g.mechanics))].sort();
out.push(
"do $guard$",
"declare",
" missing text;",
"begin",
` select string_agg(n, ', ') into missing from jsonb_array_elements_text(${jsonLit(genreNames)}) as t(n)`,
" where not exists (select 1 from public.genres g where g.name_de = t.n);",
" if missing is not null then",
" raise exception 'startbestand: Genre fehlt in der Datenbank (vocabulary.sql eingespielt?): %', missing;",
" end if;",
` select string_agg(n, ', ') into missing from jsonb_array_elements_text(${jsonLit(mechanicNames)}) as t(n)`,
" where not exists (select 1 from public.mechanics m where m.name_de = t.n);",
" if missing is not null then",
" raise exception 'startbestand: Mechanik fehlt in der Datenbank (vocabulary.sql eingespielt?): %', missing;",
" end if;",
"end",
"$guard$;",
"",
);
// 1. Verlage (ohne abgeleitete ID, Auflösung über lower(name))
const publisherNames = new Map<string, string>(); // lower -> erste Schreibweise
for (const g of games) for (const p of g.publishers) if (!publisherNames.has(p.toLowerCase())) publisherNames.set(p.toLowerCase(), p);
out.push("-- Verlage");
out.push("insert into public.publishers (name) values");
out.push([...publisherNames.values()].sort().map((p) => ` (${q(p)})`).join(",\n"));
out.push("on conflict ((lower(name))) do nothing;", "");
// 2. Spiele
const cols = "title, description, description_locale, year, min_players, max_players, min_minutes, max_minutes, min_age";
out.push("-- Spiele (unveränderte Zeilen werden nicht angefasst)");
out.push(`insert into public.games (id, ${cols}, visibility) values`);
out.push(
games.map((g) =>
` (${q(g.id)}, ${q(g.title)}, ${q(g.description)}, 'de', ${num(g.year)}, ${num(g.min_players)}, ${num(g.max_players)}, ` +
`${num(g.min_minutes)}, ${num(g.max_minutes)}, ${num(g.min_age)}, 'catalog')`
).join(",\n"),
);
const colList = cols.split(", ");
out.push("on conflict (id) do update set");
out.push(colList.map((c) => ` ${c} = excluded.${c}`).join(",\n"));
out.push(`where (${colList.map((c) => `games.${c}`).join(", ")})`);
out.push(` is distinct from (${colList.map((c) => `excluded.${c}`).join(", ")});`, "");
// 3. Entfernen = ausblenden (nur bei hidden = ja; sonst bleibt hidden_at unberührt)
const hidden = games.filter((g) => g.hidden);
if (hidden.length) {
out.push("-- hidden = ja: ausblenden, nichts löschen");
out.push(`update public.games set hidden_at = now()`);
out.push(`where hidden_at is null and id = any(array[${hidden.map((g) => q(g.id)).join(", ")}]::uuid[]);`, "");
}
// 4. Zuordnungen als Abgleich
const ids = games.map((g) => q(g.id)).join(", ");
const wantRows = (pick: (g: Game) => string[]) =>
games.flatMap((g) => pick(g).map((n) => ` (${q(g.id)}::uuid, ${q(n)})`)).join(",\n");
const reconcile = (
label: string,
linkTable: string,
fkColumn: string,
termTable: string,
termMatch: string,
pick: (g: Game) => string[],
) => {
out.push(`-- Zuordnung ${label}: fehlende anlegen, nicht mehr gewünschte löschen`);
out.push(`with want(game_id, term) as (values`);
out.push(wantRows(pick));
out.push(`), resolved as (`);
out.push(` select w.game_id, t.id as ${fkColumn} from want w join public.${termTable} t on ${termMatch}`);
out.push(`), removed as (`);
out.push(` delete from public.${linkTable} l`);
out.push(` where l.game_id = any(array[${ids}]::uuid[])`);
out.push(` and not exists (select 1 from resolved r where r.game_id = l.game_id and r.${fkColumn} = l.${fkColumn})`);
out.push(`)`);
out.push(`insert into public.${linkTable} (game_id, ${fkColumn})`);
out.push(`select game_id, ${fkColumn} from resolved`);
out.push(`on conflict (game_id, ${fkColumn}) do nothing;`, "");
};
reconcile("Genre", "game_genres", "genre_id", "genres", "t.name_de = w.term", (g) => g.genres);
reconcile("Mechanik", "game_mechanics", "mechanic_id", "mechanics", "t.name_de = w.term", (g) => g.mechanics);
reconcile("Verlag", "game_publishers", "publisher_id", "publishers", "lower(t.name) = lower(w.term)", (g) => g.publishers);
return out.join("\n");
}
// ---------------------------------------------------------------------------------------------
// Prüfabfrage checks.sql (und die Fixture-Variante für pgTAP)
// ---------------------------------------------------------------------------------------------
export function generateChecksQuery(startbestandIds: string[], vocabulary: VocabEntry[]): string {
const sbIds = [...startbestandIds].sort();
const voc = vocabulary.map((v) => ({ kind: v.kind, id: v.id, name_de: v.name_de }));
return [
`with sb(id) as (`,
` select t.value::uuid from jsonb_array_elements_text(${jsonLines(sbIds)}) as t(value)`,
`), voc(kind, id, name_de) as (`,
` select x.kind, x.id, x.name_de from jsonb_to_recordset(${jsonLines(voc)}) as x(kind text, id uuid, name_de text)`,
`)`,
`select rule, beschreibung, soll, ist, case when ok is null then 'Information' when ok then 'ok' else 'Verstoß' end as status`,
`from (`,
` select 1 as ord, 'SB0' as rule, 'Startbestandsspiele in der Datenbank vorhanden' as beschreibung,`,
` 'alle ' || (select count(*) from sb) as soll, n as ist, null::boolean as ok`,
` from (select count(*) as n from public.games g join sb on sb.id = g.id) x`,
` union all`,
` select 2, 'SB1', 'Sichtbare Katalogspiele aus dem Startbestand (Abnahme: mindestens ${MIN_VISIBLE})',`,
` '>= ${MIN_VISIBLE}', n, n >= ${MIN_VISIBLE}`,
` from (select count(*) as n from public.catalog_games c join sb on sb.id = c.id) x`,
` union all`,
` select 3, 'SB2', 'Spiele ohne Pflicht-Fakt (Jahr, Spielerzahl, Dauer, Alter, Verlag, Genre, Mechanik)', '0', n, n = 0`,
` from (select count(*) as n from public.games g join sb on sb.id = g.id`,
` where g.year is null or g.min_players is null or g.max_players is null`,
` or g.min_minutes is null or g.max_minutes is null or g.min_age is null`,
` or not exists (select 1 from public.game_publishers x where x.game_id = g.id)`,
` or not exists (select 1 from public.game_genres x where x.game_id = g.id)`,
` or not exists (select 1 from public.game_mechanics x where x.game_id = g.id)) x`,
` union all`,
` select 4, 'SB4', 'Spiele ohne deutschen Kurztext', '0', n, n = 0`,
` from (select count(*) as n from public.games g join sb on sb.id = g.id`,
` where g.description is null or g.description_locale is distinct from 'de') x`,
` union all`,
` select 5, 'SB6', 'Spiele mit Cover, Quelle oder BGG-Kennung', '0', n, n = 0`,
` from (select count(*) as n from public.games g join sb on sb.id = g.id`,
` where g.image_path is not null or g.catalog_source_id is not null or g.bgg_id is not null) x`,
` union all`,
` select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean`,
` from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id)`,
` + (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x`,
`) r`,
`order by ord`,
].join("\n");
}
export function generateChecksSql(startbestandIds: string[], vocabulary: VocabEntry[]): string {
return [
GENERATED_HEADER.trimEnd(),
"-- Prüfabfrage zur Abnahme (KAT-05 Krit. 7 und 10). Im Studio-SQL-Editor oder per psql ausführen.",
"-- Beschränkt sich auf die Spiele aus startbestand-keys.csv; Spiele, die im Studio angelegt wurden,",
"-- und das Beispielspiel aus seed.sql zählen nicht mit. Abnahme: kein Verstoß, SB1 mindestens " + MIN_VISIBLE + ".",
"-- Lokal und in CI ist SB1 nur eine Information, solange noch eingepflegt wird.",
generateChecksQuery(startbestandIds, vocabulary) + ";",
"",
].join("\n");
}
// Ausschließlich für pgTAP: legt die Prüfabfrage als temporäre View an (psql: \ir). Die IDs stammen
// aus den Testspielen in catalog/testdata/, damit die Prüfabfrage selbst getestet werden kann.
export function generateChecksFixtureInclude(startbestandIds: string[], vocabulary: VocabEntry[]): string {
return [
"-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten.",
"-- Include für pgTAP (\\ir), keine Test-Datei. Dieselbe Abfrage wie checks.sql, aber mit den IDs der",
"-- Testspiele aus supabase/catalog/testdata/.",
"create temp view checks_result as",
generateChecksQuery(startbestandIds, vocabulary) + ";",
"",
].join("\n");
}
// ---------------------------------------------------------------------------------------------
// Gesamtlauf
// ---------------------------------------------------------------------------------------------
export interface BuildInput {
vocabularyCsv: string;
startbestandCsv: string;
previousKeysCsv: string;
}
export interface BuildOutput {
"vocabulary.sql": string;
"startbestand.sql": string;
"startbestand-keys.csv": string;
"checks.sql": string;
checksFixtureInclude: string;
}
export async function build(input: BuildInput): Promise<BuildOutput> {
const vocabulary = await parseVocabulary(input.vocabularyCsv);
const previous = parseKeys(input.previousKeysCsv);
const games = await parseStartbestand(input.startbestandCsv, vocabulary, previous);
const keys = new Map(previous);
for (const g of games) keys.set(g.key, g.id);
const ids = [...keys.values()];
return {
"vocabulary.sql": generateVocabularySql(vocabulary),
"startbestand.sql": generateStartbestandSql(games),
"startbestand-keys.csv": renderKeys(keys),
"checks.sql": generateChecksSql(ids, vocabulary),
checksFixtureInclude: generateChecksFixtureInclude(ids, vocabulary),
};
}
async function main() {
const dir = new URL(".", import.meta.url);
const read = (name: string) => Deno.readTextFile(new URL(name, dir));
let previousKeysCsv = "key,uuid\n";
try {
previousKeysCsv = await read("startbestand-keys.csv");
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) throw e;
}
const real = await build({
vocabularyCsv: await read("vocabulary.csv"),
startbestandCsv: await read("startbestand.csv"),
previousKeysCsv,
});
// Testdaten (offensichtlich fiktiv) für pgTAP: eigenes Vokabular, eigene Spiele, keine Schlüsselliste.
const fixture = await build({
vocabularyCsv: await read("testdata/vocabulary.csv"),
startbestandCsv: await read("testdata/startbestand.csv"),
previousKeysCsv: "key,uuid\n",
});
for (const name of ["vocabulary.sql", "startbestand.sql", "startbestand-keys.csv", "checks.sql"] as const) {
await Deno.writeTextFile(new URL(name, dir), real[name]);
}
const support = new URL("../tests/support/", dir);
await Deno.mkdir(support, { recursive: true });
await Deno.writeTextFile(new URL("vocabulary_fixture.inc", support), fixture["vocabulary.sql"]);
await Deno.writeTextFile(new URL("startbestand_fixture.inc", support), fixture["startbestand.sql"]);
await Deno.writeTextFile(new URL("checks_fixture.inc", support), fixture.checksFixtureInclude);
console.log(
"Erzeugt: catalog/{vocabulary.sql,startbestand.sql,startbestand-keys.csv,checks.sql}, " +
"tests/support/{vocabulary,startbestand,checks}_fixture.inc",
);
}
if (import.meta.main) {
try {
await main();
} catch (e) {
if (e instanceof BuildError) {
console.error("Prüfung fehlgeschlagen:");
for (const p of e.problems) console.error(" " + p);
Deno.exit(1);
}
throw e;
}
}

View file

@ -0,0 +1,329 @@
// Tests für build_startbestand.ts. Aufruf (Repo-Root):
// deno test --allow-read=supabase supabase/catalog/
// Fixtures sind offensichtlich fiktiv ("Testspiel", "Testverlag"). Es werden keine echten Spieldaten verwendet.
import assert from "node:assert/strict";
import {
build,
BuildError,
generateStartbestandSql,
NAMESPACE,
parseCsv,
parseKeys,
parseStartbestand,
parseVocabulary,
renderKeys,
uuidV5,
} from "./build_startbestand.ts";
const VOCAB = `kind,vocab_key,name_de,name_en
genre,test-genre-a,Testgenre A,Test genre A
genre,test-genre-b,Testgenre B,Test genre B
mechanic,test-mechanic-a,Testmechanik A,Test mechanic A
`;
const HEADER =
"key,title,publishers,year,min_players,max_players,min_minutes,max_minutes,min_age,genres,mechanics,description,description_locale,hidden,facts_source,alt_titles,base_game_key";
// Zeile mit Standardwerten; einzelne Felder lassen sich überschreiben.
function row(over: Record<string, string> = {}): string {
const base: Record<string, string> = {
key: "testspiel-a", title: "Testspiel A", publishers: "Testverlag", year: "2020", min_players: "2",
max_players: "4", min_minutes: "30", max_minutes: "60", min_age: "8", genres: "Testgenre A",
mechanics: "Testmechanik A", description: "Fiktiver Text.", description_locale: "de", hidden: "",
facts_source: "Test", alt_titles: "", base_game_key: "",
};
const r = { ...base, ...over };
return HEADER.split(",").map((c) => {
const v = r[c];
return /[",\n]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v;
}).join(",");
}
const csv = (...rows: string[]) => [HEADER, ...rows].join("\n") + "\n";
async function vocab() {
return await parseVocabulary(VOCAB);
}
async function rejects(text: string, includes: RegExp, keys = new Map<string, string>()) {
const v = await vocab();
await assert.rejects(
() => parseStartbestand(text, v, keys),
(e: unknown) => {
assert.ok(e instanceof BuildError, "BuildError erwartet");
assert.match(e.problems.join("\n"), includes);
return true;
},
);
}
// ---- UUID v5 ------------------------------------------------------------------------------
Deno.test("uuidV5: Referenzwert aus RFC 4122 / Python uuid5(NAMESPACE_DNS, ...)", async () => {
assert.equal(
await uuidV5("www.example.com", "6ba7b810-9dad-11d1-80b4-00c04fd430c8"),
"2ed6657d-e927-568b-95e1-2665a8aea6a2",
);
});
Deno.test("uuidV5: gleicher key gibt gleiche UUID wie uuid_generate_v5 in PostgreSQL (siehe pgTAP 06)", async () => {
assert.equal(NAMESPACE, "6f1d9a52-3c4e-4b8a-9f27-5d0e8c71a3b4");
assert.equal(await uuidV5("game:testspiel-a"), "5189ad0e-ab5b-5500-a3e0-a0b3671cdf14");
assert.equal(await uuidV5("game:testspiel-a"), await uuidV5("game:testspiel-a"));
assert.notEqual(await uuidV5("game:testspiel-a"), await uuidV5("game:testspiel-b"));
});
// ---- CSV ----------------------------------------------------------------------------------
Deno.test("parseCsv: Anführungszeichen, doppelte Anführungszeichen, Zeilenumbruch im Feld, CRLF, BOM", () => {
const parsed = parseCsv('a,b\r\n"x, y","sagt ""hallo"""\r\n"zwei\nZeilen",z\r\n', "t.csv");
assert.deepEqual(parsed.header, ["a", "b"]);
assert.equal(parsed.rows[0].cells.a, "x, y");
assert.equal(parsed.rows[0].cells.b, 'sagt "hallo"');
assert.equal(parsed.rows[1].cells.a, "zwei\nZeilen");
assert.equal(parsed.rows[1].line, 3);
});
Deno.test("parseCsv: falsche Spaltenzahl und offenes Anführungszeichen nennen die Zeile", () => {
assert.throws(() => parseCsv("a,b\n1,2,3\n", "t.csv"), /t\.csv:2: 3 Spalten statt 2/);
assert.throws(() => parseCsv('a,b\n"offen,2\n', "t.csv"), /t\.csv:2: Anführungszeichen nicht geschlossen/);
});
Deno.test("parseCsv: nur Kopfzeile ergibt keine Zeilen", () => {
assert.equal(parseCsv("a,b\n", "t.csv").rows.length, 0);
});
// ---- Vokabular ----------------------------------------------------------------------------
Deno.test("parseVocabulary: gültig, IDs nach UUID v5", async () => {
const v = await vocab();
assert.equal(v.length, 3);
assert.equal(v[0].id, await uuidV5("genre:test-genre-a"));
assert.equal(v[2].id, await uuidV5("mechanic:test-mechanic-a"));
});
Deno.test("parseVocabulary: doppelter vocab_key, doppelter Name, falsche kind, falscher Schlüssel", async () => {
const head = "kind,vocab_key,name_de,name_en\n";
await assert.rejects(() => parseVocabulary(head + "genre,a-b,A,A\ngenre,a-b,B,B\n"), /vocabulary\.csv:3: vocab_key "a-b" doppelt/);
await assert.rejects(() => parseVocabulary(head + "genre,a-b,A,A\ngenre,c-d,a,C\n"), /vocabulary\.csv:3: name_de "a" doppelt/);
await assert.rejects(() => parseVocabulary(head + "genre,a-b,A,A\ngenre,c-d,C,a\n"), /vocabulary\.csv:3: name_en "a" doppelt/);
await assert.rejects(() => parseVocabulary(head + "kategorie,a-b,A,A\n"), /kind muss/);
await assert.rejects(() => parseVocabulary(head + "genre,A_B,A,A\n"), /vocab_key "A_B" ungültig/);
await assert.rejects(() => parseVocabulary("kind,vocab_key,name_de\ngenre,a-b,A\n"), /Spalte "name_en" fehlt/);
});
Deno.test("parseVocabulary: gleiche Namen in verschiedenen Arten sind erlaubt", async () => {
const v = await parseVocabulary("kind,vocab_key,name_de,name_en\ngenre,a-b,A,A\nmechanic,a-b,A,A\n");
assert.equal(v.length, 2);
assert.notEqual(v[0].id, v[1].id);
});
// ---- Startbestand: gültig -----------------------------------------------------------------
Deno.test("parseStartbestand: gültige Zeile (Positivfall)", async () => {
const games = await parseStartbestand(csv(row()), await vocab(), new Map());
assert.equal(games.length, 1);
assert.equal(games[0].id, await uuidV5("game:testspiel-a"));
assert.deepEqual(games[0].publishers, ["Testverlag"]);
assert.equal(games[0].hidden, false);
});
Deno.test("parseStartbestand: nur Kopfzeile ist gültig (heutiger Stand ohne Spiele)", async () => {
assert.deepEqual(await parseStartbestand(HEADER + "\n", await vocab(), new Map()), []);
});
// ---- Startbestand: eine Negativprobe je Prüfregel -----------------------------------------
Deno.test("SB2: fehlende Pflicht-Fakten", async () => {
for (const col of ["year", "min_players", "max_players", "min_minutes", "max_minutes", "min_age"]) {
await rejects(csv(row({ [col]: "" })), new RegExp(`startbestand\\.csv:2: ${col} fehlt`));
}
await rejects(csv(row({ publishers: "" })), /mindestens ein Verlag/);
await rejects(csv(row({ publishers: " ; " })), /mindestens ein Verlag/);
await rejects(csv(row({ genres: "" })), /mindestens ein Genre/);
await rejects(csv(row({ mechanics: "" })), /mindestens eine Mechanik/);
await rejects(csv(row({ title: "" })), /title muss 1-200 Zeichen/);
});
Deno.test("DB-Grenzen: Jahr, max < min, keine Zahl, negativ", async () => {
await rejects(csv(row({ year: "1799" })), /year 1799 außerhalb/);
await rejects(csv(row({ year: "2101" })), /year 2101 außerhalb/);
await rejects(csv(row({ year: "20x0" })), /year "20x0" ist keine ganze Zahl/);
await rejects(csv(row({ min_players: "0" })), /min_players 0 außerhalb/);
await rejects(csv(row({ min_players: "5", max_players: "3" })), /max_players \(3\) kleiner als min_players \(5\)/);
await rejects(csv(row({ min_minutes: "90", max_minutes: "30" })), /max_minutes \(30\) kleiner als min_minutes \(90\)/);
await rejects(csv(row({ min_age: "-1" })), /min_age "-1" ist keine ganze Zahl/);
await rejects(csv(row({ title: "x".repeat(201) })), /title muss 1-200 Zeichen/);
});
Deno.test("SB4: Kurztext Pflicht, Sprache de, Länge", async () => {
await rejects(csv(row({ description: "" })), /Kurztext fehlt/);
await rejects(csv(row({ description_locale: "en" })), /description_locale muss "de" sein/);
await rejects(csv(row({ description_locale: "" })), /description_locale muss "de" sein/);
await rejects(csv(row({ description: "x".repeat(5001) })), /länger als 5000/);
});
Deno.test("SB5: unbekanntes Genre oder unbekannte Mechanik, Doppelte", async () => {
await rejects(csv(row({ genres: "Erfundenes" })), /unbekannt in genres: "Erfundenes"/);
await rejects(csv(row({ mechanics: "Testgenre A" })), /unbekannt in mechanics/); // Genre ist keine Mechanik
await rejects(csv(row({ genres: "Testgenre A;Testgenre A" })), /genres: "Testgenre A" doppelt/);
});
Deno.test("SB6: keine Spalte für Cover, Quelle oder BGG-Kennung", async () => {
for (const col of ["image_path", "bgg_id", "catalog_source_id", "source_ref", "cover"]) {
await rejects(`${HEADER},${col}\n${row()},x\n`, new RegExp(`unbekannte Spalte "${col}"`));
}
});
Deno.test("SB7: base_game_key muss auf einen vorhandenen key zeigen", async () => {
await rejects(csv(row({ base_game_key: "gibt-es-nicht" })), /base_game_key "gibt-es-nicht" hat keinen passenden key/);
await rejects(csv(row({ base_game_key: "testspiel-a" })), /verweist auf das Spiel selbst/);
const ok = await parseStartbestand(
csv(row(), row({ key: "testspiel-b", title: "Testspiel B", base_game_key: "testspiel-a" })),
await vocab(),
new Map(),
);
assert.equal(ok.length, 2);
});
Deno.test("key: Format und Eindeutigkeit", async () => {
await rejects(csv(row({ key: "Ungültig_Key" })), /key "Ungültig_Key" ungültig/);
await rejects(csv(row({ key: "a" })), /key "a" ungültig/);
await rejects(csv(row(), row({ title: "Noch eins" })), /startbestand\.csv:3: key "testspiel-a" doppelt/);
});
Deno.test("hidden: nur leer oder ja; Verlage nicht doppelt", async () => {
await rejects(csv(row({ hidden: "nein" })), /hidden muss leer oder "ja" sein/);
await rejects(csv(row({ publishers: "Testverlag;TESTVERLAG" })), /Verlag "TESTVERLAG" doppelt/);
});
Deno.test("Pflichtspalte fehlt in der Kopfzeile", async () => {
await assert.rejects(
async () => await parseStartbestand("key,title\nx,y\n", await vocab(), new Map()),
/Spalte "publishers" fehlt/,
);
});
Deno.test("mehrere Fehler werden gesammelt, mit Zeilennummern", async () => {
const v = await vocab();
await assert.rejects(
() => parseStartbestand(csv(row({ year: "" }), row({ key: "testspiel-b", genres: "X" })), v, new Map()),
(e: unknown) => {
assert.ok(e instanceof BuildError);
assert.match(e.problems.join("\n"), /startbestand\.csv:2: year fehlt/);
assert.match(e.problems.join("\n"), /startbestand\.csv:3: unbekannt in genres/);
return true;
},
);
});
// ---- Schlüsselliste -----------------------------------------------------------------------
Deno.test("Schlüssel sind unveränderlich: ein entfernter key bricht ab", async () => {
const previous = parseKeys(`key,uuid\ntestspiel-a,${await uuidV5("game:testspiel-a")}\ntestspiel-alt,${await uuidV5("game:testspiel-alt")}\n`);
await rejects(csv(row()), /Schlüssel entfernt oder umbenannt: "testspiel-alt"/, previous);
// Umbenennen ist dasselbe wie Entfernen
await rejects(csv(row({ key: "testspiel-neu" })), /Schlüssel entfernt oder umbenannt: "testspiel-a"/, previous);
});
Deno.test("Schlüssel: falsche UUID in der Liste (Namespace geändert?)", async () => {
const previous = parseKeys("key,uuid\ntestspiel-a,00000000-0000-5000-8000-000000000000\n");
await rejects(csv(row()), /uuid zu "testspiel-a" ist 00000000-0000-5000-8000-000000000000, erwartet/, previous);
});
Deno.test("Schlüsselliste wächst nur und ist sortiert", async () => {
const first = await build({ vocabularyCsv: VOCAB, startbestandCsv: csv(row({ key: "zzz-spiel" })), previousKeysCsv: "key,uuid\n" });
const second = await build({
vocabularyCsv: VOCAB,
startbestandCsv: csv(row({ key: "zzz-spiel" }), row({ key: "aaa-spiel", title: "Neu" })),
previousKeysCsv: first["startbestand-keys.csv"],
});
const before = first["startbestand-keys.csv"].trim().split("\n");
const after = second["startbestand-keys.csv"].trim().split("\n");
for (const line of before) assert.ok(after.includes(line), `Zeile fehlt: ${line}`);
assert.deepEqual(after.slice(1).map((l) => l.split(",")[0]), ["aaa-spiel", "zzz-spiel"]);
assert.equal(renderKeys(parseKeys(second["startbestand-keys.csv"])), second["startbestand-keys.csv"]);
});
// ---- SQL-Erzeugung ------------------------------------------------------------------------
async function sqlFor(...rows: string[]): Promise<string> {
return generateStartbestandSql(await parseStartbestand(csv(...rows), await vocab(), new Map()));
}
Deno.test("hidden = ja erzeugt hidden_at, leer fasst hidden_at nicht an", async () => {
const hiddenSql = await sqlFor(row({ hidden: "ja" }));
assert.match(hiddenSql, /update public\.games set hidden_at = now\(\)/);
assert.match(hiddenSql, /where hidden_at is null and id = any/);
const plain = await sqlFor(row());
assert.doesNotMatch(plain, /hidden_at/);
assert.doesNotMatch(plain, /update public\.games set/);
});
Deno.test("facts_source, alt_titles und base_game_key landen nicht in der SQL", async () => {
const sql = await sqlFor(row({ facts_source: "GEHEIMER-NACHWEIS", alt_titles: "de:Alt", base_game_key: "" }));
assert.doesNotMatch(sql, /GEHEIMER-NACHWEIS|alt_titles|base_game_key|facts_source/);
});
Deno.test("SQL: Apostroph im Text wird verdoppelt, Upsert nutzt is distinct from, Zuordnungen werden abgeglichen", async () => {
const sql = await sqlFor(row({ title: "Wo's lang", description: "Es ist 'nett'." }));
assert.match(sql, /'Wo''s lang'/);
assert.match(sql, /'Es ist ''nett''\.'/);
assert.match(sql, /on conflict \(id\) do update set/);
assert.match(sql, /is distinct from \(excluded\.title/);
assert.match(sql, /on conflict \(\(lower\(name\)\)\) do nothing/); // Verlage
assert.match(sql, /delete from public\.game_genres/);
assert.match(sql, /delete from public\.game_mechanics/);
assert.match(sql, /delete from public\.game_publishers/);
assert.match(sql, /on conflict \(game_id, genre_id\) do nothing/);
assert.match(sql, /Genre fehlt in der Datenbank/); // Wächter gegen still verlorene Zuordnungen
assert.doesNotMatch(sql, /image_path|bgg_id|catalog_source_id|source_ref/); // SB6
});
Deno.test("SQL: leerer Startbestand ergibt eine gültige, leere Datei", async () => {
const sql = generateStartbestandSql([]);
assert.match(sql, /keine Spiele/);
assert.doesNotMatch(sql, /insert into/);
});
Deno.test("build: deterministisch, ohne Zeitstempel", async () => {
const input = { vocabularyCsv: VOCAB, startbestandCsv: csv(row(), row({ key: "testspiel-b", title: "B" })), previousKeysCsv: "key,uuid\n" };
assert.deepEqual(await build(input), await build(input));
});
Deno.test("checks.sql: bettet die Startbestands-IDs ein, Sollwert 180, leerer Bestand ist gültiges SQL", async () => {
const out = await build({ vocabularyCsv: VOCAB, startbestandCsv: csv(row()), previousKeysCsv: "key,uuid\n" });
assert.match(out["checks.sql"], new RegExp(await uuidV5("game:testspiel-a")));
assert.match(out["checks.sql"], />= 180/);
const empty = await build({ vocabularyCsv: VOCAB, startbestandCsv: HEADER + "\n", previousKeysCsv: "key,uuid\n" });
assert.match(empty["checks.sql"], /jsonb_array_elements_text\('\[\]'::jsonb\)/);
assert.doesNotMatch(empty["checks.sql"], /values \(\s*\)/);
});
Deno.test("vocabulary.sql: on conflict do nothing ohne Konfliktziel", async () => {
const out = await build({ vocabularyCsv: VOCAB, startbestandCsv: HEADER + "\n", previousKeysCsv: "key,uuid\n" });
assert.match(out["vocabulary.sql"], /insert into public\.genres \(id, name_de, name_en\) values/);
assert.match(out["vocabulary.sql"], /insert into public\.mechanics/);
assert.equal((out["vocabulary.sql"].match(/on conflict do nothing;/g) ?? []).length, 2);
});
// ---- Eingecheckte, erzeugte Dateien sind aktuell ------------------------------------------
Deno.test("die eingecheckten erzeugten Dateien entsprechen den CSV-Quellen", async () => {
const dir = new URL(".", import.meta.url);
const read = (name: string) => Deno.readTextFile(new URL(name, dir));
const real = await build({
vocabularyCsv: await read("vocabulary.csv"),
startbestandCsv: await read("startbestand.csv"),
previousKeysCsv: await read("startbestand-keys.csv"),
});
for (const name of ["vocabulary.sql", "startbestand.sql", "startbestand-keys.csv", "checks.sql"] as const) {
assert.equal(await read(name), real[name], `${name} ist nicht aktuell: build_startbestand.ts ausführen`);
}
const fixture = await build({
vocabularyCsv: await read("testdata/vocabulary.csv"),
startbestandCsv: await read("testdata/startbestand.csv"),
previousKeysCsv: "key,uuid\n",
});
const support = (name: string) => Deno.readTextFile(new URL(`../tests/support/${name}`, dir));
assert.equal(await support("vocabulary_fixture.inc"), fixture["vocabulary.sql"]);
assert.equal(await support("startbestand_fixture.inc"), fixture["startbestand.sql"]);
assert.equal(await support("checks_fixture.inc"), fixture.checksFixtureInclude);
});
Deno.test("Vokabular-Vorschlag: 12-15 Genres, 20-30 Mechaniken (Aufgabe B3a)", async () => {
const v = await parseVocabulary(await Deno.readTextFile(new URL("vocabulary.csv", import.meta.url)));
const genres = v.filter((e) => e.kind === "genre").length;
const mechanics = v.filter((e) => e.kind === "mechanic").length;
assert.ok(genres >= 12 && genres <= 15, `Genres: ${genres}`);
assert.ok(mechanics >= 20 && mechanics <= 30, `Mechaniken: ${mechanics}`);
});

View file

@ -0,0 +1,86 @@
-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten.
-- Prüfabfrage zur Abnahme (KAT-05 Krit. 7 und 10). Im Studio-SQL-Editor oder per psql ausführen.
-- Beschränkt sich auf die Spiele aus startbestand-keys.csv; Spiele, die im Studio angelegt wurden,
-- und das Beispielspiel aus seed.sql zählen nicht mit. Abnahme: kein Verstoß, SB1 mindestens 180.
-- Lokal und in CI ist SB1 nur eine Information, solange noch eingepflegt wird.
with sb(id) as (
select t.value::uuid from jsonb_array_elements_text('[]'::jsonb) as t(value)
), voc(kind, id, name_de) as (
select x.kind, x.id, x.name_de from jsonb_to_recordset('[
{"kind":"genre","id":"91c2b7c9-5a7c-5001-b90f-af05c0a8bc76","name_de":"Strategiespiel"},
{"kind":"genre","id":"ecd63153-af09-5423-8579-483c4f1dc87c","name_de":"Familienspiel"},
{"kind":"genre","id":"39c44fac-9323-5695-a02a-019620057a50","name_de":"Partyspiel"},
{"kind":"genre","id":"7f50e253-41bb-5cde-ad0a-dc48e9c9295f","name_de":"Kooperatives Spiel"},
{"kind":"genre","id":"503248a5-6424-5e06-aa9a-ec1d43fb0e28","name_de":"Kartenspiel"},
{"kind":"genre","id":"963e400e-b6c9-5719-b6f0-2440d93fff7d","name_de":"Abstraktes Spiel"},
{"kind":"genre","id":"aefb13d7-2a29-5901-bb8a-aa3f897a8e20","name_de":"Thematisches Spiel"},
{"kind":"genre","id":"f7418ea2-db50-5e8c-a737-cab1ee15b34b","name_de":"Wirtschaftsspiel"},
{"kind":"genre","id":"1b7da3a2-ace2-503f-b632-f697d651e1d0","name_de":"Kriegsspiel"},
{"kind":"genre","id":"0114584b-4a22-5986-8896-8ee491aad1c7","name_de":"Kinderspiel"},
{"kind":"genre","id":"71b582a5-c53d-5370-920f-cadc03725324","name_de":"Wortspiel"},
{"kind":"genre","id":"693bbe28-03e5-554a-9548-ab54f186bffe","name_de":"Deduktionsspiel"},
{"kind":"genre","id":"72baa1f7-835e-56d7-8341-c1c524148dd4","name_de":"Geschicklichkeitsspiel"},
{"kind":"genre","id":"d8a5a92f-05b5-5dff-b321-baaecb5803bf","name_de":"Quizspiel"},
{"kind":"mechanic","id":"00f902bc-e66b-5976-b235-7cd462ee23b6","name_de":"Arbeitereinsatz"},
{"kind":"mechanic","id":"c7fde702-f7c3-5b81-b2b5-736253974a75","name_de":"Deckbau"},
{"kind":"mechanic","id":"afb217d3-38bb-5989-8383-07fa200ded35","name_de":"Handkartenmanagement"},
{"kind":"mechanic","id":"2ca2b8c5-9604-5186-a037-7386cd3f299b","name_de":"Würfeln"},
{"kind":"mechanic","id":"2d35402f-c714-5cf7-a2e6-b035023ad51a","name_de":"Plättchenlegen"},
{"kind":"mechanic","id":"83b32eef-078a-5822-91e9-d48157476056","name_de":"Sets sammeln"},
{"kind":"mechanic","id":"c4cd186c-401b-5cc2-81cb-fcfd544c8a4d","name_de":"Drafting"},
{"kind":"mechanic","id":"7d327434-bf71-5be2-a659-429c81c54cb5","name_de":"Gebietsmehrheit"},
{"kind":"mechanic","id":"77565f86-d2e0-51c6-908a-dd1d05a08d26","name_de":"Bieten und Versteigern"},
{"kind":"mechanic","id":"96dd12a0-f303-50d4-9baf-f56d9d8aa489","name_de":"Handel"},
{"kind":"mechanic","id":"84019ba4-fc52-5343-97ad-2ea5c4f6ee6e","name_de":"Verhandlung"},
{"kind":"mechanic","id":"6e49822b-dc6b-553f-b9e8-c5685d1e953b","name_de":"Streckenbau"},
{"kind":"mechanic","id":"42521d16-2532-5583-8e41-ade8e0a15679","name_de":"Engine Building"},
{"kind":"mechanic","id":"2d4aac3a-728d-54a2-a2cb-569410a56523","name_de":"Abholen und Liefern"},
{"kind":"mechanic","id":"31d0ed7c-9dad-58da-8390-55786ee1e6c4","name_de":"Stichspiel"},
{"kind":"mechanic","id":"1c83518d-9b9f-512c-a71e-585a5650adea","name_de":"Glück herausfordern"},
{"kind":"mechanic","id":"f53a7662-1580-57e0-87d3-c79ed720a63a","name_de":"Bluffen"},
{"kind":"mechanic","id":"71a4f344-709e-5536-a5a9-325d40732509","name_de":"Verdeckte Rollen"},
{"kind":"mechanic","id":"3c02a90b-9cb3-50f8-950a-9464a2767c28","name_de":"Variable Spielerfähigkeiten"},
{"kind":"mechanic","id":"e49b1223-2605-5c52-98e3-7e0d3e571e98","name_de":"Bewegung auf Raster"},
{"kind":"mechanic","id":"3f98436d-5aab-56dc-b5e9-4d8a7b0cbfa4","name_de":"Muster bauen"},
{"kind":"mechanic","id":"de7d374a-d1c6-506b-a92f-c726b06e0563","name_de":"Echtzeit"},
{"kind":"mechanic","id":"c92e4a17-82f5-5e8a-8932-77dcad0cba29","name_de":"Gedächtnis"},
{"kind":"mechanic","id":"edec6ec8-5b68-5fc0-892b-8fd739285593","name_de":"Würfeln und Notieren"},
{"kind":"mechanic","id":"d7ef7709-0a6a-5cbd-b135-086dba038839","name_de":"Kampagne"},
{"kind":"mechanic","id":"8ed41477-247c-5570-8c61-155e6f827660","name_de":"Aktionspunkte"},
{"kind":"mechanic","id":"b90c9692-ff39-5170-a1ec-003b35d60950","name_de":"Ressourcenmanagement"},
{"kind":"mechanic","id":"ef336193-3cfb-5f69-81c6-d57f8f720871","name_de":"Erzählen"},
{"kind":"mechanic","id":"bc7940fd-3e2f-5932-b99f-31d4f55fd289","name_de":"Gegenseitig ärgern"},
{"kind":"mechanic","id":"8ec5712b-42be-525a-b60d-1b79379d6f40","name_de":"Spieler scheiden aus"}
]'::jsonb) as x(kind text, id uuid, name_de text)
)
select rule, beschreibung, soll, ist, case when ok is null then 'Information' when ok then 'ok' else 'Verstoß' end as status
from (
select 1 as ord, 'SB0' as rule, 'Startbestandsspiele in der Datenbank vorhanden' as beschreibung,
'alle ' || (select count(*) from sb) as soll, n as ist, null::boolean as ok
from (select count(*) as n from public.games g join sb on sb.id = g.id) x
union all
select 2, 'SB1', 'Sichtbare Katalogspiele aus dem Startbestand (Abnahme: mindestens 180)',
'>= 180', n, n >= 180
from (select count(*) as n from public.catalog_games c join sb on sb.id = c.id) x
union all
select 3, 'SB2', 'Spiele ohne Pflicht-Fakt (Jahr, Spielerzahl, Dauer, Alter, Verlag, Genre, Mechanik)', '0', n, n = 0
from (select count(*) as n from public.games g join sb on sb.id = g.id
where g.year is null or g.min_players is null or g.max_players is null
or g.min_minutes is null or g.max_minutes is null or g.min_age is null
or not exists (select 1 from public.game_publishers x where x.game_id = g.id)
or not exists (select 1 from public.game_genres x where x.game_id = g.id)
or not exists (select 1 from public.game_mechanics x where x.game_id = g.id)) x
union all
select 4, 'SB4', 'Spiele ohne deutschen Kurztext', '0', n, n = 0
from (select count(*) as n from public.games g join sb on sb.id = g.id
where g.description is null or g.description_locale is distinct from 'de') x
union all
select 5, 'SB6', 'Spiele mit Cover, Quelle oder BGG-Kennung', '0', n, n = 0
from (select count(*) as n from public.games g join sb on sb.id = g.id
where g.image_path is not null or g.catalog_source_id is not null or g.bgg_id is not null) x
union all
select 6, 'Info', 'Begriffe mit abweichender ID (im Studio angelegt, bitte prüfen)', '-', n, null::boolean
from (select (select count(*) from public.genres t join voc v on v.kind = 'genre' and v.name_de = t.name_de where t.id <> v.id)
+ (select count(*) from public.mechanics t join voc v on v.kind = 'mechanic' and v.name_de = t.name_de where t.id <> v.id) as n) x
) r
order by ord;

View file

@ -0,0 +1 @@
key,uuid
1 key uuid

View file

@ -0,0 +1 @@
key,title,publishers,year,min_players,max_players,min_minutes,max_minutes,min_age,genres,mechanics,description,description_locale,hidden,facts_source,alt_titles,base_game_key
1 key title publishers year min_players max_players min_minutes max_minutes min_age genres mechanics description description_locale hidden facts_source alt_titles base_game_key

View file

@ -0,0 +1,6 @@
-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten.
-- Startbestand (KAT-05). Idempotent bis zum Go-live; danach eingefroren (docs/katalog-pflege.md).
-- Reihenfolge: Migrationen, vocabulary.sql, dann diese Datei.
-- startbestand.csv enthält keine Spiele.

View file

@ -0,0 +1,4 @@
key,title,publishers,year,min_players,max_players,min_minutes,max_minutes,min_age,genres,mechanics,description,description_locale,hidden,facts_source,alt_titles,base_game_key
testspiel-a,Testspiel A,Testverlag,2020,2,4,30,60,8,Testgenre A;Testgenre B,Testmechanik A,Fiktives Testspiel A. Nur für automatische Tests.,de,,Testdaten,,
testspiel-b,Testspiel B,Testverlag;Zweiter Testverlag,2021,1,5,10,20,6,Testgenre A,Testmechanik A;Testmechanik B,"Fiktives Testspiel B, mit Komma.",de,,Testdaten,,testspiel-a
testspiel-c,Testspiel C: Wo's lang geht,TESTVERLAG,1999,3,3,45,45,12,Testgenre B,Testmechanik B,Fiktives Testspiel C mit Apostroph im Titel.,de,ja,Testdaten,,
1 key title publishers year min_players max_players min_minutes max_minutes min_age genres mechanics description description_locale hidden facts_source alt_titles base_game_key
2 testspiel-a Testspiel A Testverlag 2020 2 4 30 60 8 Testgenre A;Testgenre B Testmechanik A Fiktives Testspiel A. Nur für automatische Tests. de Testdaten
3 testspiel-b Testspiel B Testverlag;Zweiter Testverlag 2021 1 5 10 20 6 Testgenre A Testmechanik A;Testmechanik B Fiktives Testspiel B, mit Komma. de Testdaten testspiel-a
4 testspiel-c Testspiel C: Wo's lang geht TESTVERLAG 1999 3 3 45 45 12 Testgenre B Testmechanik B Fiktives Testspiel C mit Apostroph im Titel. de ja Testdaten

View file

@ -0,0 +1,5 @@
kind,vocab_key,name_de,name_en
genre,test-genre-a,Testgenre A,Test genre A
genre,test-genre-b,Testgenre B,Test genre B
mechanic,test-mechanic-a,Testmechanik A,Test mechanic A
mechanic,test-mechanic-b,Testmechanik B,Test mechanic B
1 kind vocab_key name_de name_en
2 genre test-genre-a Testgenre A Test genre A
3 genre test-genre-b Testgenre B Test genre B
4 mechanic test-mechanic-a Testmechanik A Test mechanic A
5 mechanic test-mechanic-b Testmechanik B Test mechanic B

View file

@ -0,0 +1,45 @@
kind,vocab_key,name_de,name_en
genre,strategy,Strategiespiel,Strategy game
genre,family,Familienspiel,Family game
genre,party,Partyspiel,Party game
genre,cooperative,Kooperatives Spiel,Cooperative game
genre,card-game,Kartenspiel,Card game
genre,abstract,Abstraktes Spiel,Abstract game
genre,thematic,Thematisches Spiel,Thematic game
genre,economic,Wirtschaftsspiel,Economic game
genre,wargame,Kriegsspiel,Wargame
genre,kids,Kinderspiel,Children's game
genre,word,Wortspiel,Word game
genre,deduction,Deduktionsspiel,Deduction game
genre,dexterity,Geschicklichkeitsspiel,Dexterity game
genre,trivia,Quizspiel,Trivia game
mechanic,worker-placement,Arbeitereinsatz,Worker placement
mechanic,deck-building,Deckbau,Deck building
mechanic,hand-management,Handkartenmanagement,Hand management
mechanic,dice-rolling,Würfeln,Dice rolling
mechanic,tile-placement,Plättchenlegen,Tile placement
mechanic,set-collection,Sets sammeln,Set collection
mechanic,drafting,Drafting,Drafting
mechanic,area-control,Gebietsmehrheit,Area control
mechanic,auction,Bieten und Versteigern,Auction and bidding
mechanic,trading,Handel,Trading
mechanic,negotiation,Verhandlung,Negotiation
mechanic,route-building,Streckenbau,Route building
mechanic,engine-building,Engine Building,Engine building
mechanic,pick-up-and-deliver,Abholen und Liefern,Pick-up and deliver
mechanic,trick-taking,Stichspiel,Trick-taking
mechanic,push-your-luck,Glück herausfordern,Push your luck
mechanic,bluffing,Bluffen,Bluffing
mechanic,hidden-roles,Verdeckte Rollen,Hidden roles
mechanic,variable-powers,Variable Spielerfähigkeiten,Variable player powers
mechanic,grid-movement,Bewegung auf Raster,Grid movement
mechanic,pattern-building,Muster bauen,Pattern building
mechanic,real-time,Echtzeit,Real-time
mechanic,memory,Gedächtnis,Memory
mechanic,roll-and-write,Würfeln und Notieren,Roll and write
mechanic,campaign,Kampagne,Campaign
mechanic,action-points,Aktionspunkte,Action points
mechanic,resource-management,Ressourcenmanagement,Resource management
mechanic,storytelling,Erzählen,Storytelling
mechanic,take-that,Gegenseitig ärgern,Take that
mechanic,player-elimination,Spieler scheiden aus,Player elimination
1 kind vocab_key name_de name_en
2 genre strategy Strategiespiel Strategy game
3 genre family Familienspiel Family game
4 genre party Partyspiel Party game
5 genre cooperative Kooperatives Spiel Cooperative game
6 genre card-game Kartenspiel Card game
7 genre abstract Abstraktes Spiel Abstract game
8 genre thematic Thematisches Spiel Thematic game
9 genre economic Wirtschaftsspiel Economic game
10 genre wargame Kriegsspiel Wargame
11 genre kids Kinderspiel Children's game
12 genre word Wortspiel Word game
13 genre deduction Deduktionsspiel Deduction game
14 genre dexterity Geschicklichkeitsspiel Dexterity game
15 genre trivia Quizspiel Trivia game
16 mechanic worker-placement Arbeitereinsatz Worker placement
17 mechanic deck-building Deckbau Deck building
18 mechanic hand-management Handkartenmanagement Hand management
19 mechanic dice-rolling Würfeln Dice rolling
20 mechanic tile-placement Plättchenlegen Tile placement
21 mechanic set-collection Sets sammeln Set collection
22 mechanic drafting Drafting Drafting
23 mechanic area-control Gebietsmehrheit Area control
24 mechanic auction Bieten und Versteigern Auction and bidding
25 mechanic trading Handel Trading
26 mechanic negotiation Verhandlung Negotiation
27 mechanic route-building Streckenbau Route building
28 mechanic engine-building Engine Building Engine building
29 mechanic pick-up-and-deliver Abholen und Liefern Pick-up and deliver
30 mechanic trick-taking Stichspiel Trick-taking
31 mechanic push-your-luck Glück herausfordern Push your luck
32 mechanic bluffing Bluffen Bluffing
33 mechanic hidden-roles Verdeckte Rollen Hidden roles
34 mechanic variable-powers Variable Spielerfähigkeiten Variable player powers
35 mechanic grid-movement Bewegung auf Raster Grid movement
36 mechanic pattern-building Muster bauen Pattern building
37 mechanic real-time Echtzeit Real-time
38 mechanic memory Gedächtnis Memory
39 mechanic roll-and-write Würfeln und Notieren Roll and write
40 mechanic campaign Kampagne Campaign
41 mechanic action-points Aktionspunkte Action points
42 mechanic resource-management Ressourcenmanagement Resource management
43 mechanic storytelling Erzählen Storytelling
44 mechanic take-that Gegenseitig ärgern Take that
45 mechanic player-elimination Spieler scheiden aus Player elimination

View file

@ -0,0 +1,54 @@
-- Erzeugt von supabase/catalog/build_startbestand.ts. Nicht von Hand bearbeiten.
-- Vokabular (Genres, Mechaniken). Idempotent. Ein im Studio angelegter Begriff mit gleichem Namen
-- gewinnt (on conflict do nothing fängt ID- und Namenskonflikte); startbestand.sql löst über name_de auf.
insert into public.genres (id, name_de, name_en) values
('91c2b7c9-5a7c-5001-b90f-af05c0a8bc76', 'Strategiespiel', 'Strategy game'),
('ecd63153-af09-5423-8579-483c4f1dc87c', 'Familienspiel', 'Family game'),
('39c44fac-9323-5695-a02a-019620057a50', 'Partyspiel', 'Party game'),
('7f50e253-41bb-5cde-ad0a-dc48e9c9295f', 'Kooperatives Spiel', 'Cooperative game'),
('503248a5-6424-5e06-aa9a-ec1d43fb0e28', 'Kartenspiel', 'Card game'),
('963e400e-b6c9-5719-b6f0-2440d93fff7d', 'Abstraktes Spiel', 'Abstract game'),
('aefb13d7-2a29-5901-bb8a-aa3f897a8e20', 'Thematisches Spiel', 'Thematic game'),
('f7418ea2-db50-5e8c-a737-cab1ee15b34b', 'Wirtschaftsspiel', 'Economic game'),
('1b7da3a2-ace2-503f-b632-f697d651e1d0', 'Kriegsspiel', 'Wargame'),
('0114584b-4a22-5986-8896-8ee491aad1c7', 'Kinderspiel', 'Children''s game'),
('71b582a5-c53d-5370-920f-cadc03725324', 'Wortspiel', 'Word game'),
('693bbe28-03e5-554a-9548-ab54f186bffe', 'Deduktionsspiel', 'Deduction game'),
('72baa1f7-835e-56d7-8341-c1c524148dd4', 'Geschicklichkeitsspiel', 'Dexterity game'),
('d8a5a92f-05b5-5dff-b321-baaecb5803bf', 'Quizspiel', 'Trivia game')
on conflict do nothing;
insert into public.mechanics (id, name_de, name_en) values
('00f902bc-e66b-5976-b235-7cd462ee23b6', 'Arbeitereinsatz', 'Worker placement'),
('c7fde702-f7c3-5b81-b2b5-736253974a75', 'Deckbau', 'Deck building'),
('afb217d3-38bb-5989-8383-07fa200ded35', 'Handkartenmanagement', 'Hand management'),
('2ca2b8c5-9604-5186-a037-7386cd3f299b', 'Würfeln', 'Dice rolling'),
('2d35402f-c714-5cf7-a2e6-b035023ad51a', 'Plättchenlegen', 'Tile placement'),
('83b32eef-078a-5822-91e9-d48157476056', 'Sets sammeln', 'Set collection'),
('c4cd186c-401b-5cc2-81cb-fcfd544c8a4d', 'Drafting', 'Drafting'),
('7d327434-bf71-5be2-a659-429c81c54cb5', 'Gebietsmehrheit', 'Area control'),
('77565f86-d2e0-51c6-908a-dd1d05a08d26', 'Bieten und Versteigern', 'Auction and bidding'),
('96dd12a0-f303-50d4-9baf-f56d9d8aa489', 'Handel', 'Trading'),
('84019ba4-fc52-5343-97ad-2ea5c4f6ee6e', 'Verhandlung', 'Negotiation'),
('6e49822b-dc6b-553f-b9e8-c5685d1e953b', 'Streckenbau', 'Route building'),
('42521d16-2532-5583-8e41-ade8e0a15679', 'Engine Building', 'Engine building'),
('2d4aac3a-728d-54a2-a2cb-569410a56523', 'Abholen und Liefern', 'Pick-up and deliver'),
('31d0ed7c-9dad-58da-8390-55786ee1e6c4', 'Stichspiel', 'Trick-taking'),
('1c83518d-9b9f-512c-a71e-585a5650adea', 'Glück herausfordern', 'Push your luck'),
('f53a7662-1580-57e0-87d3-c79ed720a63a', 'Bluffen', 'Bluffing'),
('71a4f344-709e-5536-a5a9-325d40732509', 'Verdeckte Rollen', 'Hidden roles'),
('3c02a90b-9cb3-50f8-950a-9464a2767c28', 'Variable Spielerfähigkeiten', 'Variable player powers'),
('e49b1223-2605-5c52-98e3-7e0d3e571e98', 'Bewegung auf Raster', 'Grid movement'),
('3f98436d-5aab-56dc-b5e9-4d8a7b0cbfa4', 'Muster bauen', 'Pattern building'),
('de7d374a-d1c6-506b-a92f-c726b06e0563', 'Echtzeit', 'Real-time'),
('c92e4a17-82f5-5e8a-8932-77dcad0cba29', 'Gedächtnis', 'Memory'),
('edec6ec8-5b68-5fc0-892b-8fd739285593', 'Würfeln und Notieren', 'Roll and write'),
('d7ef7709-0a6a-5cbd-b135-086dba038839', 'Kampagne', 'Campaign'),
('8ed41477-247c-5570-8c61-155e6f827660', 'Aktionspunkte', 'Action points'),
('b90c9692-ff39-5170-a1ec-003b35d60950', 'Ressourcenmanagement', 'Resource management'),
('ef336193-3cfb-5f69-81c6-d57f8f720871', 'Erzählen', 'Storytelling'),
('bc7940fd-3e2f-5932-b99f-31d4f55fd289', 'Gegenseitig ärgern', 'Take that'),
('8ec5712b-42be-525a-b60d-1b79379d6f40', 'Spieler scheiden aus', 'Player elimination')
on conflict do nothing;

View file

@ -69,7 +69,10 @@ schema_paths = []
enabled = true enabled = true
# Specifies an ordered list of seed files to load during db reset. # Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql" # Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"] # Reihenfolge: seed.sql (nur Entwicklung), Vokabular, Startbestand (KAT-05, erzeugt von catalog/build_startbestand.ts).
# Eine nicht gefundene Datei überspringt die CLI nur mit Warnung ("no files matched pattern"), der Reset schlägt dann
# nicht fehl. Fehler in einer Datei brechen den Reset ab (Exit 1); die Datei wird als Ganzes zurückgerollt.
sql_paths = ["./seed.sql", "./catalog/vocabulary.sql", "./catalog/startbestand.sql"]
[db.network_restrictions] [db.network_restrictions]
# Enable management of network restrictions. # Enable management of network restrictions.

View file

@ -1,14 +1,6 @@
-- Beispieldaten nur für die lokale Entwicklung (supabase db reset). Nicht für Produktion. -- Beispieldaten nur für die lokale Entwicklung (supabase db reset). Nicht für Produktion.
insert into public.genres (name_de, name_en) values -- Genres und Mechaniken kommen aus supabase/catalog/vocabulary.sql (KAT-05), nicht von hier:
('Strategie', 'Strategy'), ('Familie', 'Family'), ('Kooperativ', 'Cooperative'), -- seed.sql läuft zuerst, ein Begriff mit zufälliger ID würde das Vokabular verdrängen.
('Party', 'Party'), ('Kartenspiel', 'Card game')
on conflict do nothing;
insert into public.mechanics (name_de, name_en) values
('Handkartenmanagement', 'Hand management'), ('Worker Placement', 'Worker placement'),
('Deckbau', 'Deck building'), ('Würfeln', 'Dice rolling')
on conflict do nothing;
insert into public.categories (name_de, name_en) values insert into public.categories (name_de, name_en) values
('Wirtschaft', 'Economic'), ('Fantasy', 'Fantasy'), ('Abenteuer', 'Adventure'), ('Zivilisation', 'Civilization') ('Wirtschaft', 'Economic'), ('Fantasy', 'Fantasy'), ('Abenteuer', 'Adventure'), ('Zivilisation', 'Civilization')
on conflict do nothing; on conflict do nothing;