Files
tickte-system/scripts/hustler-import.mjs
T

963 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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) {
// --ohne-woms: bewusst nur mit pixelwelt-v2 arbeiten (entfernt vorhandene graue Raum-Ebenen und setzt das Haus auf v2 zurück)
const ohneWoms = argumente.includes('--ohne-woms')
const pfade = argumente.filter((a) => a !== '--ohne-woms')
if (pfade.includes('--help') || pfade.includes('-h') || pfade.length < 1 || pfade.length > 2) {
console.log('Aufruf: node scripts/hustler-import.mjs <pfad-zu-pixelwelt-v2> <pfad-zu-pixelwelt-woms> (oder bewusst ohne zweiten Ordner: --ohne-woms)')
return pfade.length >= 1 && pfade.length <= 2 ? 0 : 2
}
const v2 = path.resolve(pfade[0])
let woms = pfade[1] ? path.resolve(pfade[1]) : null
const hinweise = []
if (woms && !existsSync(woms)) woms = null
// Ohne den Ordner mit dem neu gezeichneten Haus würden die eingecheckten grauen Raum-Ebenen gelöscht und haus.png auf v2 zurückgesetzt
// (Endprüfung R6): das nur auf ausdrücklichen Wunsch
const hausImRepo = path.join(REPO, ASSETS, 'haus')
const graueImRepo = existsSync(hausImRepo) && readdirSync(hausImRepo).some((n) => /^raum-grau-[a-z0-9_-]+\.png$/i.test(n))
if (!woms && graueImRepo && !ohneWoms) {
throw new ImportFehler('Der Ordner pixelwelt-woms fehlt (zweites Argument), im Repo liegen aber graue Raum-Ebenen. Ohne ihn würden sie gelöscht und das Haus auf pixelwelt-v2 zurückgesetzt. Ordner angeben oder bewusst --ohne-woms setzen.')
}
if (!woms && ohneWoms) hinweise.push('Hinweis: --ohne-woms – es wird nur mit pixelwelt-v2 gearbeitet.')
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
},
)
}