Hustler-Pixelwelt T1+T2: Import der Zeichenfläche (Bilder, CSS mit hk-Vorsatz, Geometrie), helles Haus mit grauen Raum-Ebenen, Press Start 2P selbst gehostet

- scripts/hustler-import.mjs liest pixelwelt-v2 (+ pixelwelt-woms/haus) und schreibt src/assets/hustler, src/styles/hustler/{hochhaus,keller,pult}.css und src/lib/hustlerWelt.js; idempotent
- haus.png mit allen Räumen hell, acht graue Ebenen für die Such-Räume (GRAUE_RAEUME, .hk-raum-grau-<raum>)
- Press Start 2P lateinisch aus @fontsource/press-start-2p@5.2.7 (OFL), noch nirgends importiert
- 40 neue Tests (Assets, Geometrie, CSS-Umformung)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-22 07:55:25 +00:00
1 parent 6e24868995
commit 8bd365b1fc
195 files changed
+2697

No files matched your search

+954
View File
@@ -0,0 +1,954 @@
#!/usr/bin/env node
// Spielt die Pixel-Oberfläche „Hustler“ aus der Claude-Design-Zeichenfläche (pixelwelt-v2) in WOMS ein.
//
// Aufruf: node scripts/hustler-import.mjs <pfad-zu-pixelwelt-v2> [<pfad-zu-pixelwelt-woms>]
//
// Erzeugt (alles deterministisch, ein zweiter Lauf ändert keine Datei):
// src/assets/hustler/<quellpfad ohne "entwurf/"> die benutzten PNGs (Vite hängt beim Bauen einen Hash an)
// src/styles/hustler/{hochhaus,keller,pult}.css das CSS der drei Artboards, auf WOMS zugeschnitten
// src/lib/hustlerWelt.js Geometrie des Hochhauses (reine Daten, eingefroren)
//
// Der optionale zweite Ordner (pixelwelt-woms) enthält das neu gezeichnete Haus. Eine Datei dort unter
// haus/ ersetzt die gleichnamige aus pixelwelt-v2/haus/. Nennt dort haus/manifest.json „graueRaeume“,
// entstehen zusätzlich die Ebenen für abgedunkelte Räume (CSS-Klassen hk-raum-grau-<raum> und GRAUE_RAEUME).
// Erwartetes Format: graueRaeume = { "<raum>": { "file": "raum-grau-<raum>.png", "x": …, "y": …, "w": …, "h": … } }
// (x/y in Weltkoordinaten wie alle Werte der Haus-Manifeste; file, w und h sind optional – ohne Angabe gilt
// raum-grau-<raum>.png in seiner Pixelgröße). Statt eines Objekts geht auch eine Liste mit „raum“ je Eintrag.
//
// Nur Node-Bordmittel. Schreibt ausschließlich in die drei Zielorte oben; gelöscht werden höchstens alte
// Ebenen src/assets/hustler/haus/raum-grau-*.png, die der aktuelle Lauf nicht mehr erzeugt.
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const HIER = path.dirname(fileURLToPath(import.meta.url))
const REPO = path.resolve(HIER, '..')
const ROSTER_DATEI = path.join(REPO, 'src/lib/workersRoster.js')
const QUELLEN_DATEI = path.join(HIER, 'hustler-import/quellen.json')
// Ausgabeorte (relativ zur Repo-Wurzel, mit /)
const ASSETS = 'src/assets/hustler'
const STYLES = 'src/styles/hustler'
const WELT_DATEI = 'src/lib/hustlerWelt.js'
// Die drei Bildschirme: Artboard, Blob-Zuordnung, Wurzelklasse, Keyframe-Vorsatz, Ausgabedatei
export const BILDSCHIRME = Object.freeze({
hochhaus: { artboard: 'Main.dc.html', titel: 'Hochhaus', html: 'entwurf/Main.dc.html', blobs: 'entwurf/blobs.json', wurzel: 'hk-hochhaus', kfVorsatz: 'hk-h-', css: 'hochhaus.css' },
keller: { artboard: 'BotLeads.dc.html', titel: 'Keller', html: 'keller/entwurf/BotLeads.dc.html', blobs: 'keller/entwurf/blobs.json', wurzel: 'hk-keller', kfVorsatz: 'hk-k-', css: 'keller.css' },
pult: { artboard: 'Freigabe.dc.html', titel: 'Pult', html: 'pult/entwurf/Freigabe.dc.html', blobs: 'pult/entwurf/blobs.json', wurzel: 'hk-pult', kfVorsatz: 'hk-p-', css: 'pult.css' },
})
// Raum-Schlüssel im Haus-Manifest -> Raum-Schlüssel in ROOM_TITLES (workersRoster.js)
const RAUM_NACH_ROSTER = Object.freeze({
bruecke: 'bruecke', radar: 'netz', klein: 'kleinanzeigen', portale: 'portale', foren: 'foren',
aerzte: 'aerzte', handwerk: 'handwerk', selbst: 'selbst', laeden: 'laden', waage: 'waage',
mail: 'mail', forum: 'forum', post: 'posteingang', abschluss: 'abschluss',
tisch1: 'tisch1', tisch2: 'tisch2', tisch3: 'tisch3', pruef: 'pruefung',
freigabe: 'pult', tresor: 'tresor', keller: 'archiv',
})
export class ImportFehler extends Error {
constructor(text) {
super(text)
this.name = 'ImportFehler'
}
}
const fehler = (text) => new ImportFehler(text)
const sha256 = (daten) => createHash('sha256').update(daten).digest('hex')
const kurz = (text, n = 70) => {
const t = String(text).replace(/\s+/g, ' ').trim()
return t.length > n ? `${t.slice(0, n)}…` : t
}
// ───────────────────────────── CSS-Tokenizer ─────────────────────────────
// Kleiner, strenger Zerleger: Kommentare, Zeichenketten, url(…), verschachtelte @media/@supports, @keyframes.
function stringEnde(s, i) {
const q = s[i]
let j = i + 1
while (j < s.length) {
const c = s[j]
if (c === '\\') { j += 2; continue }
if (c === q) return j + 1
if (c === '\n') break
j += 1
}
throw fehler(`CSS: Zeichenkette ohne Ende bei „${kurz(s.slice(i, i + 40))}“.`)
}
// Entfernt /* … */ (Zeichenketten und ungequotetes url(…) bleiben unberührt)
function ohneKommentare(s) {
let aus = ''
let i = 0
while (i < s.length) {
const c = s[i]
if (c === '"' || c === "'") { const e = stringEnde(s, i); aus += s.slice(i, e); i = e; continue }
if (c === '\\') { aus += s.slice(i, i + 2); i += 2; continue }
if (c === '/' && s[i + 1] === '*') {
const e = s.indexOf('*/', i + 2)
if (e < 0) throw fehler('CSS: Kommentar ohne Ende.')
aus += ' '
i = e + 2
continue
}
if ((c === 'u' || c === 'U') && /^url\(\s*[^\s"')]/i.test(s.slice(i, i + 12)) && !/[\w-]/.test(s[i - 1] ?? ' ')) {
const e = s.indexOf(')', i)
if (e < 0) throw fehler('CSS: url( ohne schließende Klammer.')
aus += s.slice(i, e + 1)
i = e + 1
continue
}
aus += c
i += 1
}
return aus
}
// Erste Stelle ab i, an der ein Zeichen aus `stopps` auf oberster Ebene steht (außerhalb von (), [] und Zeichenketten)
function sucheOben(s, i, stopps) {
let klammer = 0
let eckig = 0
while (i < s.length) {
const c = s[i]
if (c === '"' || c === "'") { i = stringEnde(s, i); continue }
if (c === '\\') { i += 2; continue }
if (c === '(') klammer += 1
else if (c === ')') klammer = Math.max(0, klammer - 1)
else if (c === '[') eckig += 1
else if (c === ']') eckig = Math.max(0, eckig - 1)
else if (klammer === 0 && eckig === 0 && stopps.includes(c)) return i
i += 1
}
return -1
}
function teileOben(s, trenner) {
const teile = []
let start = 0
for (;;) {
const e = sucheOben(s, start, trenner)
if (e < 0) { teile.push(s.slice(start)); return teile }
teile.push(s.slice(start, e))
start = e + 1
}
}
// Index der Klammer, die zu s[offen] ('(' oder '[') gehört; Zeichenketten und Escapes zählen nicht mit
function passende(s, offen) {
const auf = s[offen]
const zu = auf === '(' ? ')' : ']'
let tiefe = 0
for (let i = offen; i < s.length; i += 1) {
const c = s[i]
if (c === '"' || c === "'") { i = stringEnde(s, i) - 1; continue }
if (c === '\\') { i += 1; continue }
if (c === auf) tiefe += 1
else if (c === zu) {
tiefe -= 1
if (tiefe === 0) return i
}
}
throw fehler(`CSS: „${auf}“ ohne passende schließende Klammer in „${kurz(s.slice(offen))}“.`)
}
const GRUPPEN_REGELN = new Set(['media', 'supports', 'layer', 'container'])
// → { knoten, ende }; knoten: { art: 'regel'|'gruppe'|'keyframes'|'weg', … }
function parseListe(s, start, innen) {
const knoten = []
let i = start
for (;;) {
while (i < s.length && /\s/.test(s[i])) i += 1
if (i >= s.length) {
if (innen) throw fehler('CSS: Block ohne schließende Klammer.')
return { knoten, ende: i }
}
if (s[i] === '}') {
if (!innen) throw fehler('CSS: unerwartete schließende Klammer.')
return { knoten, ende: i + 1 }
}
const stopp = sucheOben(s, i, ';{}')
if (stopp < 0) throw fehler(`CSS: Regel „${kurz(s.slice(i))}“ endet nicht.`)
const kopf = s.slice(i, stopp).trim()
if (s[stopp] === '}') throw fehler(`CSS: „${kurz(kopf)}“ steht ohne Block.`)
if (s[stopp] === ';') {
// @import/@charset ohne Block entfallen, andere Anweisungen kennen wir nicht
if (/^@(import|charset)\b/i.test(kopf)) { knoten.push({ art: 'weg' }); i = stopp + 1; continue }
throw fehler(`CSS: nicht unterstützte Anweisung „${kurz(kopf)}“.`)
}
// Block
if (kopf.startsWith('@')) {
const name = (kopf.match(/^@([A-Za-z-]+)/)?.[1] ?? '').toLowerCase()
if (name === 'font-face' || name === 'import') {
const e = blockEnde(s, stopp)
knoten.push({ art: 'weg' })
i = e + 1
continue
}
if (/(^|-)keyframes$/.test(name)) {
const kfName = kopf.slice(kopf.indexOf(name) + name.length).trim()
if (!/^[A-Za-z_][\w-]*$/.test(kfName)) throw fehler(`CSS: ungewöhnlicher Keyframe-Name „${kurz(kfName)}“.`)
const { knoten: bilder, ende } = parseListe(s, stopp + 1, true)
knoten.push({ art: 'keyframes', at: `@${name}`, name: kfName, bilder })
i = ende
continue
}
if (GRUPPEN_REGELN.has(name)) {
const { knoten: kinder, ende } = parseListe(s, stopp + 1, true)
knoten.push({ art: 'gruppe', kopf, kinder })
i = ende
continue
}
throw fehler(`CSS: nicht unterstützte At-Regel „${kurz(kopf)}“.`)
}
const e = blockEnde(s, stopp)
knoten.push({ art: 'regel', selektor: kopf, koerper: s.slice(stopp + 1, e) })
i = e + 1
}
}
// Index der schließenden Klammer zum Block, der bei s[offen] === '{' beginnt (ohne Verschachtelung)
function blockEnde(s, offen) {
const e = sucheOben(s, offen + 1, '{}')
if (e < 0) throw fehler('CSS: Block ohne schließende Klammer.')
if (s[e] === '{') throw fehler(`CSS: verschachtelte Regel in „${kurz(s.slice(offen + 1, e))}“ wird nicht unterstützt.`)
return e
}
// ───────────────────────────── Selektoren ─────────────────────────────
function istIdentStart(s, j) {
const c = s[j]
if (c === undefined) return false
if (/[A-Za-z_\\]/.test(c) || c.charCodeAt(0) > 127) return true
return c === '-' && /[A-Za-z_\\-]/.test(s[j + 1] ?? '')
}
// Jede Klasse (.name) bekommt hk- davor; Zeichenketten und [attribute] bleiben unberührt
function praefixKlassen(s) {
let aus = ''
let i = 0
while (i < s.length) {
const c = s[i]
if (c === '"' || c === "'") { const e = stringEnde(s, i); aus += s.slice(i, e); i = e; continue }
if (c === '\\') { aus += s.slice(i, i + 2); i += 2; continue }
if (c === '[') {
const ende = passende(s, i)
aus += s.slice(i, ende + 1)
i = ende + 1
continue
}
if (c === '.' && istIdentStart(s, i + 1)) { aus += '.hk-'; i += 1; continue }
aus += c
i += 1
}
return aus
}
const WURZEL_ANFANG = /^(?:html|body|:root)(?![\w-])/i
const WURZEL_FOLGE = /^\s*(?:>\s*)?(?=(?:html|body|:root)(?![\w-]))/i
export function wandleSelektor(selektor, wurzel) {
let rest = selektor.trim()
if (!rest) throw fehler('CSS: leerer Selektor.')
let istWurzel = false
while (WURZEL_ANFANG.test(rest)) {
istWurzel = true
rest = rest.replace(WURZEL_ANFANG, '')
const folge = rest.match(WURZEL_FOLGE)
if (!folge) break
rest = rest.slice(folge[0].length)
}
if (/(^|[\s>+~(,])(?:html|body|:root)(?![\w-])/i.test(rest)) {
throw fehler(`CSS: html/body/:root mitten im Selektor „${kurz(selektor)}“ lässt sich nicht sicher umformen.`)
}
const mitKlassen = praefixKlassen(rest)
return istWurzel ? `.${wurzel}${mitKlassen}` : `.${wurzel} ${mitKlassen}`
}
function wandleSelektorListe(liste, wurzel) {
const neu = []
for (const teil of teileOben(liste, ',')) {
const s = wandleSelektor(teil, wurzel)
if (!neu.includes(s)) neu.push(s)
}
return neu.join(',')
}
// ───────────────────────────── Werte ─────────────────────────────
const BLOB_ADRESSE = /^\/_blob\/([0-9A-Za-z]+)$/
function blobInhalt(inhalt, blobPfad) {
const m = inhalt.trim().match(BLOB_ADRESSE)
if (m) return blobPfad(m[1])
if (inhalt.includes('/_blob/')) throw fehler(`CSS: unerwartete Blob-Adresse „${kurz(inhalt)}“.`)
return inhalt
}
// url(/_blob/…) und Zeichenketten mit Blob-Adresse -> Asset-Pfad
function ersetzeBlobs(wert, blobPfad) {
let aus = ''
let i = 0
while (i < wert.length) {
const c = wert[i]
if (c === '"' || c === "'") {
const e = stringEnde(wert, i)
aus += c + blobInhalt(wert.slice(i + 1, e - 1), blobPfad) + c
i = e
continue
}
if (c === '\\') { aus += wert.slice(i, i + 2); i += 2; continue }
if ((c === 'u' || c === 'U') && /^url\(/i.test(wert.slice(i, i + 4)) && !/[\w-]/.test(wert[i - 1] ?? ' ')) {
let j = i + 4
while (j < wert.length && /\s/.test(wert[j])) j += 1
if (wert[j] === '"' || wert[j] === "'") { aus += wert.slice(i, j); i = j; continue } // die Zeichenkette folgt in der Schleife
const e = wert.indexOf(')', j)
if (e < 0) throw fehler(`CSS: url( ohne schließende Klammer in „${kurz(wert)}“.`)
aus += `url(${blobInhalt(wert.slice(j, e), blobPfad)})`
i = e + 1
continue
}
aus += c
i += 1
}
return aus
}
const ZAHL = /[+-]?(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?[A-Za-z%]*/y
const IDENT = /(?:[A-Za-z_]|[^\u0000-\u007f]|\\.)(?:[\w-]|[^\u0000-\u007f]|\\.)*|-(?:[A-Za-z_-]|[^\u0000-\u007f]|\\.)(?:[\w-]|[^\u0000-\u007f]|\\.)*/y
// Bekannte Keyframe-Namen in animation/animation-name umbenennen; Schlüsselwörter, Zahlen mit Einheit
// (3200ms, -1.3s, .5s) und Funktionen (steps(1,end)) bleiben unberührt
function benenneAnimationen(wert, umbenennen) {
let aus = ''
let i = 0
while (i < wert.length) {
const c = wert[i]
if (c === '"' || c === "'") { const e = stringEnde(wert, i); aus += wert.slice(i, e); i = e; continue }
ZAHL.lastIndex = i
const zahl = ZAHL.exec(wert)
if (zahl) {
aus += zahl[0]
i += zahl[0].length
continue
}
IDENT.lastIndex = i
const ident = IDENT.exec(wert)
if (ident) {
const name = ident[0]
i += name.length
if (wert[i] === '(') {
// Funktion (steps, cubic-bezier, var …): unverändert bis zur passenden Klammer
const ende = passende(wert, i)
aus += name + wert.slice(i, ende + 1)
i = ende + 1
} else {
aus += umbenennen.has(name) ? umbenennen.get(name) : name
}
continue
}
aus += c
i += 1
}
return aus
}
function wandleDeklarationen(koerper, ctx) {
const teile = []
for (const roh of teileOben(koerper, ';')) {
const d = roh.trim()
if (!d) continue
const k = sucheOben(d, 0, ':')
if (k < 0) throw fehler(`CSS: Deklaration ohne „:“ in „${kurz(d)}“.`)
const eigenschaft = d.slice(0, k).trim()
let wert = d.slice(k + 1).trim().replace(/\s*\n\s*/g, ' ')
wert = ersetzeBlobs(wert, ctx.blobPfad)
if (/^(?:-webkit-)?animation(?:-name)?$/i.test(eigenschaft)) wert = benenneAnimationen(wert, ctx.kfNamen)
teile.push(`${eigenschaft}:${wert}`)
}
return teile.join('; ')
}
function sammleKeyframes(knoten, namen) {
for (const k of knoten) {
if (k.art === 'keyframes') namen.add(k.name)
else if (k.art === 'gruppe') sammleKeyframes(k.kinder, namen)
}
}
function schreibeKnoten(knoten, ctx, zeilen) {
for (const k of knoten) {
if (k.art === 'weg') continue
if (k.art === 'regel') {
zeilen.push(`${wandleSelektorListe(k.selektor, ctx.wurzel)}{${wandleDeklarationen(k.koerper, ctx)}}`)
} else if (k.art === 'keyframes') {
const bilder = k.bilder.map((b) => {
if (b.art !== 'regel') throw fehler(`CSS: unerwarteter Inhalt in @keyframes ${k.name}.`)
return `${b.selektor.replace(/\s+/g, ' ')}{${wandleDeklarationen(b.koerper, ctx)}}`
})
zeilen.push(`${k.at} ${ctx.kfNamen.get(k.name)}{${bilder.join('')}}`)
} else if (k.art === 'gruppe') {
const innen = []
schreibeKnoten(k.kinder, ctx, innen)
zeilen.push(`${k.kopf.replace(/\s+/g, ' ')}{`, ...innen, '}')
}
}
}
/**
* Formt das CSS eines Artboards für WOMS um.
* @param {string} css zusammengesetzter Inhalt der <style>-Blöcke
* @param {{ wurzel: string, kfVorsatz: string, blobPfad: (id: string) => string }} opt
* @returns {{ css: string, keyframes: string[] }}
*/
export function wandleCss(css, { wurzel, kfVorsatz, blobPfad }) {
const { knoten } = parseListe(ohneKommentare(css), 0, false)
const namen = new Set()
sammleKeyframes(knoten, namen)
const kfNamen = new Map([...namen].map((n) => [n, `${kfVorsatz}${n}`]))
const zeilen = []
schreibeKnoten(knoten, { wurzel, kfNamen, blobPfad }, zeilen)
const aus = zeilen.join('\n')
if (aus.includes('/_blob/')) throw fehler('CSS: nach der Umformung steht noch eine Blob-Adresse im Ergebnis.')
return { css: aus, keyframes: [...kfNamen.values()] }
}
// ───────────────────────────── HTML der Artboards ─────────────────────────────
function styleBloecke(html, artboard) {
const teile = [...html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1])
if (teile.length === 0) throw fehler(`${artboard}: kein <style>-Block gefunden.`)
return teile.join('\n')
}
const TAG = /<([A-Za-z][\w-]*)((?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'<>]+))?)*)\s*\/?>/g
const ATTR = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>]+)))?/g
function entschluessele(t) {
return t.replace(/&nbsp;/g, '\u00a0').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&amp;/g, '&')
}
function elemente(bereich) {
const liste = []
for (const m of bereich.matchAll(TAG)) {
const attr = {}
for (const a of m[2].matchAll(ATTR)) attr[a[1]] = a[2] ?? a[3] ?? a[4] ?? ''
const ende = m.index + m[0].length
const naechstes = bereich.indexOf('<', ende)
liste.push({
tag: m[1].toLowerCase(),
attr,
klassen: new Set((attr.class ?? '').split(/\s+/).filter(Boolean)),
stil: stilMap(attr.style),
text: entschluessele(bereich.slice(ende, naechstes < 0 ? undefined : naechstes)).trim(),
})
}
return liste
}
function stilMap(s = '') {
const o = {}
for (const d of teileOben(s, ';')) {
const k = d.indexOf(':')
if (k > 0) o[d.slice(0, k).trim().toLowerCase()] = d.slice(k + 1).trim()
}
return o
}
function px(wert, was) {
const m = typeof wert === 'string' ? wert.match(/^(-?\d+(?:\.\d+)?)px$/) : null
if (!m) throw fehler(`Hochhaus: ${was} ist keine px-Angabe (${wert === undefined ? 'fehlt' : `„${wert}“`}).`)
return Number(m[1])
}
const rechteck = (e, was) => ({ left: px(e.stil.left, `${was}: left`), top: px(e.stil.top, `${was}: top`), w: px(e.stil.width, `${was}: width`), h: px(e.stil.height, `${was}: height`) })
const punkt = (e, was) => ({ left: px(e.stil.left, `${was}: left`), top: px(e.stil.top, `${was}: top`) })
function genau(liste, was, bedingung) {
const treffer = liste.filter(bedingung)
if (treffer.length !== 1) throw fehler(`Hochhaus: ${was} kommt ${treffer.length}-mal in Main.dc.html vor, erwartet genau 1.`)
return treffer[0]
}
// ───────────────────────────── Geometrie des Hochhauses ─────────────────────────────
function leseHochhaus({ html, hausManifest, roster }) {
const { FLOORS, FLOOR_TITLES, ROLES, ROOM_TITLES } = roster
const kopfEnde = html.indexOf('</helmet>')
const skriptStart = html.search(/<script\b[^>]*type="text\/x-dc"/i)
if (kopfEnde < 0 || skriptStart < 0 || skriptStart < kopfEnde) throw fehler('Main.dc.html: Aufbau unbekannt (</helmet> oder das Logik-Skript fehlt).')
const markup = elemente(html.slice(kopfEnde, skriptStart))
const skript = html.slice(skriptStart)
const css = styleBloecke(html, 'Main.dc.html')
// Welt und Haus (Kamerarahmen) wie welt() in der Logik
const w = skript.match(/welt\(\)\s*\{\s*return\s*\{\s*w:\s*(\d+),\s*h:\s*(\d+),\s*haus:\s*\{\s*x:\s*(\d+),\s*y:\s*(\d+),\s*w:\s*(\d+),\s*h:\s*(\d+)\s*\},\s*hud:\s*(\d+)\s*\}/)
if (!w) throw fehler('Main.dc.html: welt() in der Logik nicht gefunden.')
const WELT = { w: Number(w[1]), h: Number(w[2]), hud: Number(w[7]) }
const HAUS = { x: Number(w[3]), y: Number(w[4]), w: Number(w[5]), h: Number(w[6]) }
const pl = hausManifest.placement
const HAUS_BILD = { x: pl.x, y: pl.y, w: pl.w, h: pl.h }
// Etagen: Klickbereiche aus den Etagen-Knöpfen, Öffnung aus dem Haus-Manifest
const knoepfe = markup.filter((e) => e.klassen.has('etage'))
if (knoepfe.length !== FLOORS.length) throw fehler(`Hochhaus: ${knoepfe.length} Etagen-Knöpfe in Main.dc.html, erwartet ${FLOORS.length}.`)
if (hausManifest.floors.length !== FLOORS.length) throw fehler(`Hochhaus: ${hausManifest.floors.length} Etagen im Haus-Manifest, erwartet ${FLOORS.length}.`)
const spalten = new Set()
const ETAGEN = FLOORS.map((id, i) => {
const b = rechteck(knoepfe[i], `Etagen-Knopf ${i + 1}`)
const beschriftung = knoepfe[i].attr['aria-label'] ?? ''
if (!beschriftung.startsWith(FLOOR_TITLES[id])) throw fehler(`Hochhaus: Etagen-Knopf ${i + 1} heißt „${beschriftung}“, erwartet ${FLOOR_TITLES[id]} (id ${id}).`)
spalten.add(`${b.left}/${b.w}`)
const f = hausManifest.floors[i]
if (f.name !== FLOOR_TITLES[id]) throw fehler(`Hochhaus: Etage ${i} heißt im Haus-Manifest „${f.name}“, erwartet ${FLOOR_TITLES[id]}.`)
const oeffnung = [f.openingY0, f.openingY1]
if (!(b.top <= oeffnung[0] && oeffnung[1] <= b.top + b.h)) throw fehler(`Hochhaus: Öffnung ${oeffnung.join('–')} von ${id} liegt nicht im Klickbereich.`)
return { id, oben: b.top, unten: b.top + b.h, oeffnung }
})
if (spalten.size !== 1) throw fehler('Hochhaus: Die Etagen-Knöpfe stehen nicht alle in derselben Spalte.')
const [spalteLinks, spalteBreite] = [...spalten][0].split('/').map(Number)
ETAGEN.forEach((e, i) => {
if (e.oben >= e.unten) throw fehler(`Hochhaus: Klickbereich von ${e.id} ist leer.`)
if (i > 0 && ETAGEN[i - 1].unten !== e.oben) throw fehler(`Hochhaus: Klickbereiche von ${ETAGEN[i - 1].id} und ${e.id} schließen nicht lückenlos an.`)
})
const ETAGEN_SPALTE = { x: spalteLinks, w: spalteBreite }
// Räume aus dem Haus-Manifest; Etiketten-Punkt = x0 + 20 / Öffnung oben + 3, gegen die Schilder in Main.dc.html geprüft
const raumReihe = Object.keys(ROOM_TITLES)
const nachRoster = new Map()
for (const r of hausManifest.rooms) {
const schluessel = RAUM_NACH_ROSTER[r.key]
if (!schluessel) throw fehler(`Hochhaus: Raum „${r.key}“ aus dem Haus-Manifest ist in RAUM_NACH_ROSTER unbekannt.`)
if (nachRoster.has(schluessel)) throw fehler(`Hochhaus: Raum ${schluessel} steht doppelt im Haus-Manifest.`)
nachRoster.set(schluessel, r)
}
const beschriftungen = markup.filter((e) => e.tag === 'span' && e.stil['font-size'] === '8px' && e.stil.color === '#4a5266')
// Die drei Räume ohne Anschluss tragen in der Vorlage statt des Etiketts einen Zettel „<Raum> / nicht angeschlossen“
const suchRaeume = roster.SEARCH_ROOMS
const zettel = markup.filter((e) => e.tag === 'span' && e.stil.background === '#f1ede4' && suchRaeume.some((r) => ROOM_TITLES[r] === e.text))
const zettelRaeume = {}
let zettelBreite = null
for (const z of zettel) {
const raumSchluessel = suchRaeume.find((r) => ROOM_TITLES[r] === z.text)
const breite = px(z.stil.width, `Zettel ${raumSchluessel}: width`)
if (zettelBreite !== null && zettelBreite !== breite) throw fehler('Hochhaus: Die „nicht angeschlossen“-Zettel sind nicht gleich breit.')
zettelBreite = breite
zettelRaeume[raumSchluessel] = punkt(z, `Zettel ${raumSchluessel}`)
}
const RAEUME = {}
for (const schluessel of raumReihe) {
const r = nachRoster.get(schluessel)
if (!r) throw fehler(`Hochhaus: Raum ${schluessel} fehlt im Haus-Manifest.`)
const etage = FLOORS[r.floor]
if (!etage) throw fehler(`Hochhaus: Raum ${schluessel} liegt auf unbekannter Etage ${r.floor}.`)
const raum = { etage, x0: r.x, x1: r.x + r.w, y0: r.y, y1: r.y + r.h, etikett: { x: r.x + 20, y: r.y + 3 } }
const schilder = beschriftungen.filter((e) => e.text === ROOM_TITLES[schluessel])
if (schilder.length > 1) throw fehler(`Hochhaus: Raumschild „${ROOM_TITLES[schluessel]}“ kommt ${schilder.length}-mal in Main.dc.html vor.`)
if (schilder.length === 1) {
const sp = punkt(schilder[0], `Raumschild ${schluessel}`)
if (sp.left !== raum.etikett.x || sp.top !== raum.etikett.y) {
throw fehler(`Hochhaus: Raumschild „${ROOM_TITLES[schluessel]}“ steht in Main.dc.html bei ${sp.left}/${sp.top}, nach dem Haus-Manifest bei ${raum.etikett.x}/${raum.etikett.y}.`)
}
} else if (!zettelRaeume[schluessel]) {
throw fehler(`Hochhaus: Für Raum „${ROOM_TITLES[schluessel]}“ gibt es in Main.dc.html weder ein Raumschild noch einen „nicht angeschlossen“-Zettel.`)
}
const eta = ETAGEN.find((e) => e.id === etage)
if (raum.y0 !== eta.oeffnung[0] || raum.y1 !== eta.oeffnung[1]) throw fehler(`Hochhaus: Raum ${schluessel} passt nicht zur Öffnung der Etage ${etage}.`)
RAEUME[schluessel] = raum
}
// Hustler: Anker aus dem Haus-Manifest, Knopf-Rechtecke und Bildschirme aus Main.dc.html
const hustlerKnoepfe = markup.filter((e) => e.klassen.has('hustler'))
const bildschirme = markup.filter((e) => e.klassen.has('bildschirm') && e.klassen.has('spr')).map((e) => punkt(e, 'Bildschirm'))
const HUSTLER = {}
const gesehen = new Set()
for (const rolle of ROLES) {
const anzeigeName = rolle.workerId === 'otto' ? 'Overseer' : rolle.name
const knopf = genau(hustlerKnoepfe, `Hustler-Knopf ${anzeigeName}`, (e) => (e.attr['aria-label'] ?? '').split(',')[0].trim() === anzeigeName)
const k = rechteck(knopf, `Knopf ${anzeigeName}`)
const arbeiter = hausManifest.workers.find((x) => x.name === anzeigeName)
if (!arbeiter) throw fehler(`Hochhaus: ${anzeigeName} fehlt unter workers im Haus-Manifest.`)
const [a, f] = arbeiter.anchor
const eintrag = { a, f, knopf: k }
if (rolle.workerId === 'otto') {
const sb = arbeiter.spriteBox
if (k.left !== sb.x || k.top !== sb.y || k.w !== sb.w || k.h !== sb.h) throw fehler('Hochhaus: Knopf des Overseers passt nicht zur Sprite-Box im Haus-Manifest.')
} else {
if (k.left !== a - 12 || k.top !== f - 31 || k.w !== 24 || k.h !== 32) throw fehler(`Hochhaus: Knopf von ${anzeigeName} (${k.left}/${k.top}) passt nicht zum Anker ${a}/${f}.`)
eintrag.bildschirm = { left: a - 40, top: f - 26 }
const s = arbeiter.screen
if (s && (s.x !== eintrag.bildschirm.left || s.y !== eintrag.bildschirm.top)) throw fehler(`Hochhaus: Bildschirm von ${anzeigeName} passt nicht zum Haus-Manifest.`)
}
const raum = RAEUME[rolle.room]
if (!(raum.x0 <= a && a <= raum.x1 && raum.y0 <= f && f <= raum.y1)) throw fehler(`Hochhaus: Anker von ${anzeigeName} liegt nicht in Raum ${rolle.room}.`)
HUSTLER[rolle.workerId] = eintrag
gesehen.add(knopf)
}
if (gesehen.size !== hustlerKnoepfe.length) throw fehler(`Hochhaus: Main.dc.html hat ${hustlerKnoepfe.length} Hustler-Knöpfe, das Roster ${ROLES.length} Rollen.`)
for (const b of bildschirme) {
const passt = Object.values(HUSTLER).some((h) => h.bildschirm && h.bildschirm.left === b.left && h.bildschirm.top === b.top)
if (!passt) throw fehler(`Hochhaus: Bildschirm bei ${b.left}/${b.top} in Main.dc.html gehört zu keinem Hustler.`)
}
const radar = punkt(genau(markup, 'Radar', (e) => e.klassen.has('radar') && e.klassen.has('spr')), 'Radar')
const ritaRaum = RAEUME[ROLES.find((r) => r.workerId === 'rita').room]
if (!(ritaRaum.x0 <= radar.left && radar.left <= ritaRaum.x1 && ritaRaum.y0 <= radar.top && radar.top <= ritaRaum.y1)) throw fehler('Hochhaus: Das Radar liegt nicht in Ritas Raum.')
HUSTLER.rita.radar = radar
// Schilder
const zaehler = genau(markup, 'Briefkasten-Zähler', (e) => e.klassen.has('huepf'))
const tresor = genau(markup, 'Tresor-Text', (e) => e.tag === 'span' && /^\d+ Deals$/.test(e.text))
const keller = genau(markup, 'Keller-Text', (e) => e.tag === 'span' && /^\d+ Bot-Leads$/.test(e.text))
const neon = genau(markup, 'Neon-Fläche', (e) => e.klassen.has('neon'))
const antenne = genau(markup, 'Antenne', (e) => e.klassen.has('antenne'))
const schrift = (e, was) => ({ schrift: px(e.stil['font-size'], `${was}: font-size`), zeile: px(e.stil['line-height'], `${was}: line-height`) })
const SCHILDER = {
briefkasten: { left: px(zaehler.stil.left, 'Zähler: left'), top: px(zaehler.stil.top, 'Zähler: top'), minW: px(zaehler.stil['min-width'], 'Zähler: min-width'), h: px(zaehler.stil.height, 'Zähler: height'), schrift: px(zaehler.stil['font-size'], 'Zähler: font-size') },
tresor: { ...punkt(tresor, 'Tresor-Text'), ...schrift(tresor, 'Tresor-Text') },
keller: { ...punkt(keller, 'Keller-Text'), ...schrift(keller, 'Keller-Text') },
neon: { ...rechteck(neon, 'Neon'), schrift: px(neon.stil['font-size'], 'Neon: font-size') },
antenne: rechteck(antenne, 'Antenne'),
nichtAngeschlossen: { breite: zettelBreite, raeume: zettelRaeume },
}
// Aufzug: Seil und Kabine aus Main.dc.html, Halte (Oberkante der Kabine je Etage) aus dem Haus-Manifest
const seil = genau(markup, 'Aufzug-Seil', (e) => e.klassen.has('seil') && e.klassen.has('haus'))
const kabine = genau(markup, 'Aufzug-Kabine', (e) => e.klassen.has('aufzug') && e.klassen.has('haus'))
const halte = hausManifest.aufzug.stopsY
if (!Array.isArray(halte) || halte.length !== FLOORS.length) throw fehler('Hochhaus: Aufzug-Halte im Haus-Manifest passen nicht zur Etagenzahl.')
const AUFZUG = { seil: rechteck(seil, 'Seil'), kabine: rechteck(kabine, 'Kabine'), halte }
if (AUFZUG.kabine.top !== halte[0]) throw fehler('Hochhaus: Die Kabine startet nicht am ersten Halt.')
// Detailansicht: Figurposition (Logik) und Overlays (Markup)
const fx = skript.match(/fx:\s*p\.hut\s*===\s*'overseer'\s*\?\s*(\d+)\s*:\s*\(p\.st\s*===\s*'nicht'\s*\?\s*(\d+)\s*:\s*(\d+)\)/)
const fy = skript.match(/fy:\s*p\.hut\s*===\s*'overseer'\s*\?\s*(\d+)\s*:\s*\(p\.st\s*===\s*'nicht'\s*\?\s*(\d+)\s*:\s*(\d+)\)/)
if (!fx || !fy) throw fehler('Main.dc.html: Figurposition (fx/fy) in der Logik nicht gefunden.')
const szene = css.match(/\.sz\{[^}]*?width:\s*(\d+)px;\s*height:\s*(\d+)px/)
if (!szene) throw fehler('Main.dc.html: CSS-Regel .sz (Größe der Detail-Szene) nicht gefunden.')
const overlay = (klasse) => punkt(genau(markup, `Overlay ${klasse}`, (e) => e.klassen.has(klasse) && e.klassen.has('spr')), `Overlay ${klasse}`)
const schild = genau(markup, 'Detail-Schild', (e) => e.text === 'noch nicht angeschlossen')
const DETAIL = {
szene: { w: Number(szene[1]), h: Number(szene[2]) },
figur: {
bruecke: { left: Number(fx[1]), top: Number(fy[1]) },
nicht: { left: Number(fx[2]), top: Number(fy[2]) },
buero: { left: Number(fx[3]), top: Number(fy[3]) },
},
monitor: overlay('monitor-gross'),
dampf: overlay('dampf'),
zzz: overlay('zzz-gross'),
wirbel: overlay('wirbel'),
schild: rechteck(schild, 'Detail-Schild'),
}
return { WELT, HAUS, HAUS_BILD, ETAGEN, ETAGEN_SPALTE, RAEUME, HUSTLER, SCHILDER, AUFZUG, DETAIL }
}
// ───────────────────────────── JS-Ausgabe (reine Daten, eingefroren) ─────────────────────────────
const SCHLUESSEL = /^[A-Za-z_$][\w$]*$/
const blatt = (x) => x === null || typeof x !== 'object'
const einfach = (x) => blatt(x) || (Array.isArray(x) && x.every(blatt))
function jsLiteral(v, ein = 0) {
const pad = ' '.repeat(ein)
if (Array.isArray(v)) {
const teile = v.map((x) => jsLiteral(x, ein + 1))
if (v.every(blatt)) return `Object.freeze([${teile.join(', ')}])`
return `Object.freeze([\n${teile.map((t) => `${pad} ${t}`).join(',\n')},\n${pad}])`
}
if (v && typeof v === 'object') {
const eintraege = Object.entries(v)
if (eintraege.length === 0) return 'Object.freeze({})'
const teile = eintraege.map(([k, x]) => `${SCHLUESSEL.test(k) ? k : jsLiteral(k)}: ${jsLiteral(x, ein + 1)}`)
const flach = eintraege.every(([, x]) => einfach(x))
if (flach && teile.join(', ').length <= 100) return `Object.freeze({ ${teile.join(', ')} })`
return `Object.freeze({\n${teile.map((t) => `${pad} ${t}`).join(',\n')},\n${pad}})`
}
if (typeof v === 'string') return `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n')}'`
if (typeof v === 'number' && !Number.isFinite(v)) throw fehler('Geometrie: keine endliche Zahl.')
return String(v)
}
function weltDatei(d, kopf) {
const abschnitte = [
['WELT', 'Weltgröße in Kunstpixeln; hud = Höhe der Kopfzeile, die beim Einpassen frei bleibt', d.WELT],
['HAUS', 'Kamerarahmen um das Haus (Einpassen, „ganzes Haus“)', d.HAUS],
['HAUS_BILD', 'Lage von haus.png und den Ebenen gleicher Größe (Fenster, Schild, graue Räume)', d.HAUS_BILD],
['ETAGEN_SPALTE', 'Spalte der Etagen-Knöpfe (links, Breite)', d.ETAGEN_SPALTE],
['ETAGEN', 'Etagen von oben nach unten: id wie FLOORS; oben/unten = Klickbereich (lückenlos); oeffnung = [oben, unten] des sichtbaren Innenraums', d.ETAGEN],
['RAEUME', 'Räume nach ROOM_TITLES-Schlüssel: Rechteck x0/x1/y0/y1, Etage, Etiketten-Punkt (x0 + 20, Öffnung oben + 3)', d.RAEUME],
['HUSTLER', 'Hustler nach workerId: Anker a/f (Fußpunkt), Knopf-Rechteck, Bildschirm-Overlay, Radar (nur Rita); otto = Overseer', d.HUSTLER],
['SCHILDER', 'Briefkasten-Zähler, Tresor-/Keller-Text, Neon-Fläche, Antenne, „nicht angeschlossen“-Zettel', d.SCHILDER],
['AUFZUG', 'Seil, Kabine und Halte (Oberkante der Kabine je Etage, Reihenfolge wie ETAGEN)', d.AUFZUG],
['DETAIL', 'Detailansicht: Szenengröße, Figurposition je Szene (Büro, nicht angeschlossen, Brücke) und Overlays', d.DETAIL],
['GRAUE_RAEUME', 'Abgedunkelte Raum-Ebenen (nur wenn das neue Haus welche liefert): Raum -> left/top/width/height', d.GRAUE_RAEUME],
]
const teile = [`// ${kopf}`, '// Geometrie des Hochhauses in Weltkoordinaten. Reine Daten, tief eingefroren, keine Importe.', '']
for (const [name, hinweis, wert] of abschnitte) {
teile.push(`// ${hinweis}`, `export const ${name} = ${jsLiteral(wert)}`, '')
}
return teile.join('\n')
}
// ───────────────────────────── Eingaben lesen ─────────────────────────────
function lies(pfad, was) {
if (!existsSync(pfad)) throw fehler(`${was} fehlt: ${pfad}`)
return readFileSync(pfad)
}
function liesJson(pfad, was) {
const text = lies(pfad, was).toString('utf8')
try {
return JSON.parse(text)
} catch (e) {
throw fehler(`${was} ist kein gültiges JSON (${pfad}): ${e.message}`)
}
}
async function ladeRoster() {
const quelle = readFileSync(ROSTER_DATEI)
// als Daten-URL laden: die Datei hat keine Importe, und es gibt keine „Modultyp“-Warnung wegen package.json
return import(`data:text/javascript;base64,${quelle.toString('base64')}`)
}
function pngMasse(inhalt, was) {
const kopf = inhalt.subarray(0, 24)
if (kopf.length < 24 || kopf.toString('latin1', 1, 4) !== 'PNG' || kopf.readUInt32BE(0) !== 0x89504e47) throw fehler(`Keine gültige PNG-Datei: ${was}`)
return { w: kopf.readUInt32BE(16), h: kopf.readUInt32BE(20) }
}
function ladeHashes(v2) {
const datei = path.join(v2, 'entwurf/hochgeladen.sha256')
const karte = new Map()
if (!existsSync(datei)) return karte
for (const zeile of readFileSync(datei, 'utf8').split('\n')) {
const m = zeile.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/)
if (m) karte.set(m[2], m[1])
}
return karte
}
function leseGraueRaeume({ woms, roster }) {
if (!woms) return []
const manifestPfad = path.join(woms, 'haus/manifest.json')
if (!existsSync(manifestPfad)) return []
const g = liesJson(manifestPfad, 'haus/manifest.json im Ordner pixelwelt-woms').graueRaeume
if (g === undefined || g === null) return []
const eintraege = Array.isArray(g) ? g.map((e) => [e?.raum ?? e?.room ?? e?.key, e]) : Object.entries(g)
const liste = []
for (const [raum, e] of eintraege) {
if (!roster.SEARCH_ROOMS.includes(raum)) throw fehler(`graueRaeume: „${raum}“ ist kein Such-Raum (erlaubt: ${roster.SEARCH_ROOMS.join(', ')}).`)
if (liste.some((x) => x.raum === raum)) throw fehler(`graueRaeume: Raum „${raum}“ steht doppelt.`)
const datei = e.datei ?? e.file ?? `raum-grau-${raum}.png`
if (path.basename(datei) !== datei || !/\.png$/i.test(datei)) throw fehler(`graueRaeume: ungültiger Dateiname „${datei}“ für ${raum}.`)
const inhalt = lies(path.join(woms, 'haus', datei), `Ebene ${datei} (graueRaeume.${raum})`)
const masse = pngMasse(inhalt, `haus/${datei}`)
const x = e.x ?? e.left
const y = e.y ?? e.top
const w = e.w ?? e.width ?? masse.w
const h = e.h ?? e.height ?? masse.h
for (const [n, v] of [['x', x], ['y', y], ['w', w], ['h', h]]) {
if (!Number.isInteger(v)) throw fehler(`graueRaeume.${raum}: „${n}“ fehlt oder ist keine ganze Zahl.`)
}
if (w !== masse.w || h !== masse.h) throw fehler(`graueRaeume.${raum}: ${w}×${h} im Manifest, aber ${datei} ist ${masse.w}×${masse.h}.`)
liste.push({ raum, datei, x, y, w, h, inhalt })
}
// feste Reihenfolge wie SEARCH_ROOMS
return roster.SEARCH_ROOMS.map((r) => liste.find((x) => x.raum === r)).filter(Boolean)
}
// ───────────────────────────── Planen ─────────────────────────────
/**
* Berechnet alle Ausgaben im Speicher (schreibt nichts).
* @returns {Promise<{ dateien: Map<string, Buffer|string>, bericht: object }>} Schlüssel = Pfad relativ zur Repo-Wurzel
*/
export async function baue({ v2, woms = null, quellen = null }) {
if (!existsSync(v2) || !statSync(v2).isDirectory()) throw fehler(`Der Ordner pixelwelt-v2 existiert nicht: ${v2}`)
const tabellen = quellen ?? liesJson(QUELLEN_DATEI, 'scripts/hustler-import/quellen.json')
const roster = await ladeRoster()
const hashes = ladeHashes(v2)
const wombsHaus = woms && existsSync(path.join(woms, 'haus')) ? path.join(woms, 'haus') : null
const dateien = new Map()
const bericht = { bildschirme: {}, warnungen: [] }
const quellInfo = new Map() // Quellpfad (relativ) -> { ziel, inhalt, ueberschrieben }
function holeQuelle(rel, wo) {
if (quellInfo.has(rel)) return quellInfo.get(rel)
if (typeof rel !== 'string' || !rel || path.isAbsolute(rel) || rel.split('/').includes('..') || !/\.png$/i.test(rel)) {
throw fehler(`quellen.json: ungültiger Quellpfad „${rel}“ (${wo}).`)
}
let pfad = path.join(v2, rel)
let ueberschrieben = false
if (wombsHaus && rel.startsWith('haus/') && existsSync(path.join(wombsHaus, rel.slice('haus/'.length)))) {
pfad = path.join(wombsHaus, rel.slice('haus/'.length))
ueberschrieben = true
}
if (!existsSync(pfad) || !statSync(pfad).isFile()) throw fehler(`Quelldatei fehlt: ${rel} (${wo}); erwartet unter ${pfad}`)
const inhalt = readFileSync(pfad)
pngMasse(inhalt, rel)
if (!ueberschrieben && hashes.has(rel) && sha256(inhalt) !== hashes.get(rel)) {
throw fehler(`Prüfsumme stimmt nicht: ${rel} (entwurf/hochgeladen.sha256 nennt ${hashes.get(rel).slice(0, 12)}…, die Datei hat ${sha256(inhalt).slice(0, 12)}…).`)
}
const ziel = `${ASSETS}/${rel.replace(/(^|\/)entwurf\//g, '$1')}`
const vorhanden = dateien.get(ziel)
if (vorhanden && !vorhanden.equals(inhalt)) throw fehler(`Zwei verschiedene Quellen würden nach ${ziel} kopiert (${rel}).`)
dateien.set(ziel, inhalt)
const info = { ziel, inhalt, ueberschrieben }
quellInfo.set(rel, info)
return info
}
const namen = Object.keys(BILDSCHIRME)
for (const k of Object.keys(tabellen)) if (!namen.includes(k)) throw fehler(`quellen.json: unbekannter Bildschirm „${k}“.`)
const htmls = {}
const css = {}
for (const name of namen) {
const cfg = BILDSCHIRME[name]
const tabelle = tabellen[name]
if (!tabelle) throw fehler(`quellen.json: Bildschirm „${name}“ fehlt.`)
htmls[name] = lies(path.join(v2, cfg.html), `Artboard ${cfg.artboard}`).toString('utf8')
const blobs = liesJson(path.join(v2, cfg.blobs), `Blob-Zuordnung ${cfg.blobs}`)
for (const k of Object.keys(tabelle)) {
if (!(k in blobs)) throw fehler(`quellen.json (${name}): Schlüssel „${k}“ steht nicht in ${cfg.blobs}.`)
}
const idZuSchluessel = new Map()
for (const [k, v] of Object.entries(blobs)) {
const id = String(v).replace(/^\/_blob\//, '')
if (idZuSchluessel.has(id)) throw fehler(`${cfg.blobs}: die Adresse ${v} gehört zu zwei Schlüsseln (${idZuSchluessel.get(id)}, ${k}).`)
idZuSchluessel.set(id, k)
}
const benutzt = new Set()
const umgeformt = wandleCss(styleBloecke(htmls[name], cfg.artboard), {
wurzel: cfg.wurzel,
kfVorsatz: cfg.kfVorsatz,
blobPfad: (id) => {
const schluessel = idZuSchluessel.get(id)
if (!schluessel) throw fehler(`Im CSS von ${cfg.artboard} steht /_blob/${id}, aber ${cfg.blobs} kennt diese Adresse nicht.`)
const rel = tabelle[schluessel]
if (!rel) throw fehler(`Der Schlüssel „${schluessel}“ (${cfg.blobs}, Bildschirm ${name}) fehlt in scripts/hustler-import/quellen.json.`)
const info = holeQuelle(rel, `Schlüssel „${schluessel}“, Bildschirm ${name}`)
benutzt.add(rel)
return `../../${info.ziel.slice('src/'.length)}`
},
})
css[name] = { text: umgeformt.css, keyframes: umgeformt.keyframes }
bericht.bildschirme[name] = {
dateien: benutzt.size,
bytes: [...benutzt].reduce((s, rel) => s + quellInfo.get(rel).inhalt.length, 0),
keyframes: umgeformt.keyframes.length,
ueberschrieben: [...benutzt].filter((rel) => quellInfo.get(rel).ueberschrieben),
}
}
// graue Räume (optional, nur mit pixelwelt-woms)
const graue = leseGraueRaeume({ woms, roster })
const grauZeilen = []
const GRAUE_RAEUME = {}
for (const g of graue) {
const ziel = `${ASSETS}/haus/${g.datei}`
dateien.set(ziel, g.inhalt)
grauZeilen.push(`.${BILDSCHIRME.hochhaus.wurzel} .hk-raum-grau-${g.raum}{position:absolute; left:${g.x}px; top:${g.y}px; width:${g.w}px; height:${g.h}px; background-image:url(../../${ziel.slice('src/'.length)}); image-rendering:pixelated; pointer-events:none}`)
GRAUE_RAEUME[g.raum] = { left: g.x, top: g.y, width: g.w, height: g.h }
}
bericht.graueRaeume = graue.map((g) => g.raum)
// Geometrie
const hausManifest = liesJson(path.join(v2, 'haus/manifest.json'), 'haus/manifest.json im Ordner pixelwelt-v2')
const geometrie = leseHochhaus({ html: htmls.hochhaus, hausManifest, roster })
geometrie.GRAUE_RAEUME = GRAUE_RAEUME
for (const name of namen) {
const cfg = BILDSCHIRME[name]
const kopf = `Erzeugt von scripts/hustler-import.mjs aus ${cfg.artboard} (${cfg.titel}), nicht von Hand ändern.`
const zeilen = [`/* ${kopf} */`, css[name].text]
if (name === 'hochhaus' && grauZeilen.length) zeilen.push('/* Abgedunkelte Räume (nur sichtbar, wenn der Dienst „nicht angeschlossen“ meldet) */', ...grauZeilen)
dateien.set(`${STYLES}/${cfg.css}`, `${zeilen.join('\n')}\n`)
}
dateien.set(WELT_DATEI, weltDatei(geometrie, `Erzeugt von scripts/hustler-import.mjs aus ${BILDSCHIRME.hochhaus.artboard} (${BILDSCHIRME.hochhaus.titel}), nicht von Hand ändern.`))
bericht.einzigartig = {
dateien: [...quellInfo.keys()].length + graue.length,
bytes: [...quellInfo.values()].reduce((s, i) => s + i.inhalt.length, 0) + graue.reduce((s, g) => s + g.inhalt.length, 0),
}
return { dateien, bericht }
}
// Prüfsumme über alle Ausgaben (sortierte Pfade + Inhalte): zwei Läufe mit gleichen Eingaben müssen sie gleich liefern
export function pruefsumme(dateien) {
const h = createHash('sha256')
for (const pfad of [...dateien.keys()].sort()) h.update(`${pfad}\0${sha256(dateien.get(pfad))}\n`)
return h.digest('hex')
}
// ───────────────────────────── Schreiben ─────────────────────────────
export function schreibe(plan, wurzel = REPO) {
const erlaubt = [ASSETS, STYLES].map((p) => path.join(wurzel, p) + path.sep)
const zaehler = { geschrieben: 0, unveraendert: 0, entfernt: [] }
for (const [rel, inhalt] of plan.dateien) {
const ziel = path.join(wurzel, rel)
if (!(rel === WELT_DATEI || erlaubt.some((e) => ziel.startsWith(e)))) throw fehler(`Interner Fehler: ${rel} liegt außerhalb der erlaubten Zielorte.`)
const neu = typeof inhalt === 'string' ? Buffer.from(inhalt, 'utf8') : inhalt
if (existsSync(ziel) && readFileSync(ziel).equals(neu)) { zaehler.unveraendert += 1; continue }
mkdirSync(path.dirname(ziel), { recursive: true })
writeFileSync(ziel, neu)
zaehler.geschrieben += 1
}
// alte graue Ebenen, die dieser Lauf nicht mehr erzeugt (nur diese Dateinamen, nur dieser eine Ordner)
const hausOrdner = path.join(wurzel, ASSETS, 'haus')
if (existsSync(hausOrdner)) {
for (const name of readdirSync(hausOrdner)) {
if (/^raum-grau-[a-z0-9_-]+\.png$/i.test(name) && !plan.dateien.has(`${ASSETS}/haus/${name}`)) {
unlinkSync(path.join(hausOrdner, name))
zaehler.entfernt.push(name)
}
}
}
return zaehler
}
// ───────────────────────────── Programm ─────────────────────────────
const mb = (b) => `${(b / 1024 / 1024).toFixed(2).replace('.', ',')} MB`
export async function main(argumente) {
if (argumente.includes('--help') || argumente.includes('-h') || argumente.length < 1 || argumente.length > 2) {
console.log('Aufruf: node scripts/hustler-import.mjs <pfad-zu-pixelwelt-v2> [<pfad-zu-pixelwelt-woms>]')
return argumente.length >= 1 && argumente.length <= 2 ? 0 : 2
}
const v2 = path.resolve(argumente[0])
let woms = argumente[1] ? path.resolve(argumente[1]) : null
const hinweise = []
if (woms && !existsSync(woms)) {
hinweise.push(`Hinweis: Der Ordner ${woms} gibt es noch nicht; es wird nur mit pixelwelt-v2 gearbeitet.`)
woms = null
}
const plan = await baue({ v2, woms })
const z = schreibe(plan)
for (const h of hinweise) console.log(h)
for (const [name, b] of Object.entries(plan.bericht.bildschirme)) {
console.log(`${name.padEnd(9)} ${String(b.dateien).padStart(3)} PNG (${mb(b.bytes)}), ${b.keyframes} Keyframes${b.ueberschrieben.length ? `, ${b.ueberschrieben.length} Datei(en) aus pixelwelt-woms` : ''}`)
}
console.log(`zusammen ${String(plan.bericht.einzigartig.dateien).padStart(3)} verschiedene PNG (${mb(plan.bericht.einzigartig.bytes)})`)
if (plan.bericht.graueRaeume.length) console.log(`graue Räume: ${plan.bericht.graueRaeume.join(', ')}`)
console.log(`geschrieben: ${z.geschrieben}, unverändert: ${z.unveraendert}${z.entfernt.length ? `, entfernt: ${z.entfernt.join(', ')}` : ''}`)
console.log(`Prüfsumme aller Ausgaben: ${pruefsumme(plan.dateien)}`)
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main(process.argv.slice(2)).then(
(code) => { process.exitCode = code },
(e) => {
console.error(e instanceof ImportFehler ? `Abbruch: ${e.message}` : e)
process.exitCode = 1
},
)
}