diff --git a/public/fonts/OFL-PressStart2P.txt b/public/fonts/OFL-PressStart2P.txt new file mode 100644 index 0000000..5891186 --- /dev/null +++ b/public/fonts/OFL-PressStart2P.txt @@ -0,0 +1,93 @@ +Copyright 2012 The Press Start 2P Project Authors (cody@zone38.net), with Reserved Font Name "Press Start 2P" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/PressStart2P-Regular.woff2 b/public/fonts/PressStart2P-Regular.woff2 new file mode 100644 index 0000000..947a979 Binary files /dev/null and b/public/fonts/PressStart2P-Regular.woff2 differ diff --git a/scripts/hustler-import.mjs b/scripts/hustler-import.mjs new file mode 100644 index 0000000..a720fa7 --- /dev/null +++ b/scripts/hustler-import.mjs @@ -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 [] +// +// Erzeugt (alles deterministisch, ein zweiter Lauf ändert keine Datei): +// src/assets/hustler/ 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- und GRAUE_RAEUME). +// Erwartetes Format: graueRaeume = { "": { "file": "raum-grau-.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-.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 ', '.neu{background:url(/_blob/ffffffffffffffffffffffffffffffff)}')) + await assert.rejects(baue({ v2: tmp }), (e) => e instanceof ImportFehler && /BotLeads\.dc\.html steht \/_blob\/ffffffffffffffffffffffffffffffff, aber .*blobs\.json kennt diese Adresse nicht/.test(e.message)) + } finally { + loesche(tmp) + } +}) + +test('baue mit pixelwelt-woms: gleichnamige Haus-Dateien ersetzen die aus v2, graue Räume werden Klassen und Daten, alte Ebenen verschwinden wieder', opt, async () => { + const tmp = mkdtempSync(path.join(os.tmpdir(), 'hustler-woms-')) + const wurzel = mkdtempSync(path.join(os.tmpdir(), 'hustler-wurzel-')) + try { + mkdirSync(path.join(tmp, 'haus')) + cpSync(path.join(V2, 'haus/schild-a.png'), path.join(tmp, 'haus/haus.png')) + cpSync(path.join(V2, 'ui/panel.png'), path.join(tmp, 'haus/raum-grau-netz.png')) + cpSync(path.join(V2, 'ui/knopf.png'), path.join(tmp, 'haus/raum-grau-kleinanzeigen.png')) + writeFileSync(path.join(tmp, 'haus/manifest.json'), JSON.stringify({ graueRaeume: { netz: { x: 1040, y: 558, w: 24, h: 24 }, kleinanzeigen: { file: 'raum-grau-kleinanzeigen.png', x: 1140, y: 558 } } })) + const ohne = await baue({ v2: V2 }) + const mit = await baue({ v2: V2, woms: tmp }) + assert.notEqual(pruefsumme(ohne.dateien), pruefsumme(mit.dateien)) + assert.ok(mit.dateien.get('src/assets/hustler/haus/haus.png').equals(readFileSync(path.join(V2, 'haus/schild-a.png'))), 'haus.png stammt aus pixelwelt-woms') + assert.ok(mit.dateien.get('src/assets/hustler/haus/haus-fenster-a.png').equals(ohne.dateien.get('src/assets/hustler/haus/haus-fenster-a.png')), 'andere Dateien bleiben aus v2') + assert.deepEqual(mit.bericht.graueRaeume, ['netz', 'kleinanzeigen']) + const css = mit.dateien.get('src/styles/hustler/hochhaus.css') + assert.match(css, /\.hk-hochhaus \.hk-raum-grau-netz\{position:absolute; left:1040px; top:558px; width:24px; height:24px; background-image:url\(\.\.\/\.\.\/assets\/hustler\/haus\/raum-grau-netz\.png\); image-rendering:pixelated; pointer-events:none\}/) + assert.match(css, /\.hk-hochhaus \.hk-raum-grau-kleinanzeigen\{position:absolute; left:1140px; top:558px; width:24px; height:24px;/) + const welt = mit.dateien.get('src/lib/hustlerWelt.js') + assert.match(welt, /export const GRAUE_RAEUME = Object\.freeze\(\{\s*netz: Object\.freeze\(\{ left: 1040, top: 558, width: 24, height: 24 \}\),\s*kleinanzeigen:/) + // schreiben, nochmal schreiben (nichts mehr zu tun), dann ohne woms: die grauen Ebenen werden entfernt + const z1 = schreibe(mit, wurzel) + assert.equal(z1.geschrieben, mit.dateien.size) + assert.equal(schreibe(mit, wurzel).geschrieben, 0) + assert.ok(existsSync(path.join(wurzel, 'src/assets/hustler/haus/raum-grau-netz.png'))) + const z2 = schreibe(ohne, wurzel) + assert.deepEqual(z2.entfernt.sort(), ['raum-grau-kleinanzeigen.png', 'raum-grau-netz.png']) + assert.ok(!existsSync(path.join(wurzel, 'src/assets/hustler/haus/raum-grau-netz.png'))) + // Fehler: kein Such-Raum, falsche Größe + writeFileSync(path.join(tmp, 'haus/manifest.json'), JSON.stringify({ graueRaeume: { bruecke: { x: 1, y: 1 } } })) + await assert.rejects(baue({ v2: V2, woms: tmp }), /„bruecke“ ist kein Such-Raum/) + writeFileSync(path.join(tmp, 'haus/manifest.json'), JSON.stringify({ graueRaeume: { netz: { x: 1040, y: 558, w: 99, h: 24 } } })) + await assert.rejects(baue({ v2: V2, woms: tmp }), /99×24 im Manifest, aber raum-grau-netz\.png ist 24×24/) + // ein Ordner ohne graueRaeume ändert an den Räumen nichts + writeFileSync(path.join(tmp, 'haus/manifest.json'), JSON.stringify({})) + assert.deepEqual((await baue({ v2: V2, woms: tmp })).bericht.graueRaeume, []) + } finally { + loesche(tmp) + loesche(wurzel) + } +}) diff --git a/tests/unit/hustlerWelt.test.mjs b/tests/unit/hustlerWelt.test.mjs new file mode 100644 index 0000000..ac0697d --- /dev/null +++ b/tests/unit/hustlerWelt.test.mjs @@ -0,0 +1,167 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +// Geometrie des Hochhauses (src/lib/hustlerWelt.js, vom Import-Skript erzeugt) gegen das Roster + +const W = await loadModule('src/lib/hustlerWelt.js') +const R = await loadModule('src/lib/workersRoster.js') +const { WELT, HAUS, HAUS_BILD, ETAGEN, ETAGEN_SPALTE, RAEUME, HUSTLER, SCHILDER, AUFZUG, DETAIL, GRAUE_RAEUME } = W +const { ROLES, FLOORS, ROOM_TITLES, SEARCH_ROOMS, roleOf } = R + +function tiefEingefroren(wert, pfad = 'wert') { + if (wert === null || typeof wert !== 'object') return + assert.ok(Object.isFrozen(wert), `${pfad} ist nicht eingefroren`) + for (const [k, v] of Object.entries(wert)) tiefEingefroren(v, `${pfad}.${k}`) +} + +test('Welt und Haus: Größen wie in der Zeichenfläche', () => { + assert.deepEqual({ ...WELT }, { w: 2400, h: 1200, hud: 84 }) + assert.deepEqual({ ...HAUS }, { x: 930, y: 366, w: 570, h: 706 }) + assert.deepEqual({ ...HAUS_BILD }, { x: 930, y: 370, w: 570, h: 700 }) + assert.ok(HAUS.x >= 0 && HAUS.x + HAUS.w <= WELT.w && HAUS.y >= 0 && HAUS.y + HAUS.h <= WELT.h) + assert.ok(HAUS_BILD.x >= HAUS.x && HAUS_BILD.y >= HAUS.y && HAUS_BILD.x + HAUS_BILD.w <= HAUS.x + HAUS.w && HAUS_BILD.y + HAUS_BILD.h <= HAUS.y + HAUS.h) +}) + +test('alle Geometrie-Daten sind tief eingefroren', () => { + for (const [name, wert] of Object.entries(W)) tiefEingefroren(wert, name) +}) + +test('ETAGEN: Ids gleich FLOORS in Reihenfolge, Klickbereiche lückenlos von oben nach unten', () => { + assert.deepEqual(ETAGEN.map((e) => e.id), [...FLOORS]) + for (let i = 0; i < ETAGEN.length; i += 1) { + const e = ETAGEN[i] + assert.ok(e.oben < e.unten, `${e.id}: Klickbereich leer`) + if (i > 0) assert.equal(ETAGEN[i - 1].unten, e.oben, `${ETAGEN[i - 1].id} und ${e.id} schließen nicht lückenlos an`) + const [o, u] = e.oeffnung + assert.ok(e.oben <= o && o < u && u <= e.unten, `${e.id}: Öffnung ${o}-${u} nicht im Klickbereich ${e.oben}-${e.unten}`) + } + assert.equal(ETAGEN[0].oben, 476) + assert.equal(ETAGEN.at(-1).unten, 1062) + assert.deepEqual({ ...ETAGEN_SPALTE }, { x: 960, w: 512 }) + // Etagen links vom Raum-Bereich, Spalte deckt ihn ab + assert.ok(ETAGEN_SPALTE.x <= 1040 && ETAGEN_SPALTE.x + ETAGEN_SPALTE.w >= 1440) +}) + +test('RAEUME: jeder Raum aus ROOM_TITLES hat ein Rechteck innerhalb des Hauses', () => { + assert.deepEqual(Object.keys(RAEUME).sort(), Object.keys(ROOM_TITLES).sort()) + for (const [schluessel, r] of Object.entries(RAEUME)) { + assert.ok(r.x0 < r.x1 && r.y0 < r.y1, `${schluessel}: leeres Rechteck`) + assert.ok(r.x0 >= HAUS.x && r.x1 <= HAUS.x + HAUS.w && r.y0 >= HAUS.y && r.y1 <= HAUS.y + HAUS.h, `${schluessel}: außerhalb von HAUS`) + const etage = ETAGEN.find((e) => e.id === r.etage) + assert.ok(etage, `${schluessel}: unbekannte Etage ${r.etage}`) + assert.deepEqual([r.y0, r.y1], [...etage.oeffnung], `${schluessel}: y-Bereich ungleich Öffnung der Etage`) + // Etiketten-Punkt: x0 + 20, Öffnung oben + 3 + assert.deepEqual({ ...r.etikett }, { x: r.x0 + 20, y: r.y0 + 3 }, `${schluessel}: Etikett`) + } +}) + +test('RAEUME: die Räume einer Etage füllen 1040 bis 1440 ohne Lücke und ohne Überlappung', () => { + for (const etage of FLOORS) { + const raeume = Object.values(RAEUME).filter((r) => r.etage === etage).sort((a, b) => a.x0 - b.x0) + assert.ok(raeume.length >= 1, `${etage}: kein Raum`) + assert.equal(raeume[0].x0, 1040, `${etage}: Anfang`) + assert.equal(raeume.at(-1).x1, 1440, `${etage}: Ende`) + for (let i = 1; i < raeume.length; i += 1) assert.equal(raeume[i - 1].x1, raeume[i].x0, `${etage}: Lücke oder Überlappung`) + } + // Worker-Räume je Etage wie im Roster + for (const rolle of ROLES) assert.equal(RAEUME[rolle.room].etage, rolle.floor, `${rolle.workerId}: Raum ${rolle.room} liegt auf der falschen Etage`) + assert.equal(RAEUME.pult.etage, 'freigabe') + assert.equal(RAEUME.tresor.etage, 'tresor') +}) + +test('HUSTLER: jede Rolle hat einen Eintrag, nicht mehr und nicht weniger', () => { + assert.deepEqual(Object.keys(HUSTLER).sort(), ROLES.map((r) => r.workerId).sort()) + assert.equal(Object.keys(HUSTLER).length, 19) +}) + +test('HUSTLER: Anker liegt im Rechteck des Raums (otto auf der Brücke)', () => { + for (const [id, h] of Object.entries(HUSTLER)) { + const raum = RAEUME[roleOf(id).room] + assert.ok(raum, `${id}: Raum fehlt`) + assert.ok(h.a >= raum.x0 && h.a <= raum.x1, `${id}: a=${h.a} nicht in ${raum.x0}-${raum.x1}`) + assert.ok(h.f >= raum.y0 && h.f <= raum.y1, `${id}: f=${h.f} nicht in ${raum.y0}-${raum.y1}`) + // Fußpunkt der Hustler am Schreibtisch: 4 px über der Unterkante der Öffnung (Spec der Zeichenfläche); + // der Overseer schwebt über der Holo-Konsole und steht höher + if (id === 'otto') assert.ok(h.f < raum.y1 - 4, 'Overseer schwebt über dem Boden') + else assert.equal(h.f, raum.y1 - 4, `${id}: Fuß nicht 4 px über dem Boden`) + } + assert.equal(roleOf('otto').room, 'bruecke') + assert.equal(RAEUME[roleOf('otto').room].etage, 'bruecke') +}) + +test('HUSTLER: Knopf-Rechteck = Anker (left a-12, top f-31, 24x32), Overseer eigene Größe', () => { + for (const [id, h] of Object.entries(HUSTLER)) { + if (id === 'otto') continue + assert.deepEqual({ ...h.knopf }, { left: h.a - 12, top: h.f - 31, w: 24, h: 32 }, id) + } + const o = HUSTLER.otto + assert.deepEqual({ ...o.knopf }, { left: 1236, top: 497, w: 28, h: 40 }) + // Sprite-Anker (14, 39) sitzt auf dem Anker + assert.equal(o.knopf.left + 14, o.a) + assert.equal(o.knopf.top + 39, o.f) + assert.deepEqual([o.a, o.f], [1250, 536]) +}) + +test('HUSTLER: Bildschirm bei a-40/f-26 für alle außer otto, Radar nur bei Rita', () => { + for (const [id, h] of Object.entries(HUSTLER)) { + if (id === 'otto') { + assert.equal(h.bildschirm, undefined, 'Overseer hat keinen Schreibtisch-Bildschirm') + } else { + assert.deepEqual({ ...h.bildschirm }, { left: h.a - 40, top: h.f - 26 }, id) + } + if (id === 'rita') assert.deepEqual({ ...h.radar }, { left: 1118, top: 566 }) + else assert.equal(h.radar, undefined, `${id}: unerwartetes Radar`) + } + const rita = RAEUME.netz + assert.ok(HUSTLER.rita.radar.left >= rita.x0 && HUSTLER.rita.radar.left + 20 <= rita.x1) +}) + +test('HUSTLER: Anker verschiedener Hustler im selben Raum liegen nicht aufeinander', () => { + const gesehen = new Map() + for (const [id, h] of Object.entries(HUSTLER)) { + const schluessel = `${h.a}/${h.f}` + assert.ok(!gesehen.has(schluessel), `${id} und ${gesehen.get(schluessel)} stehen auf demselben Punkt`) + gesehen.set(schluessel, id) + } +}) + +test('SCHILDER, AUFZUG und DETAIL: Positionen vorhanden und im erwarteten Bereich', () => { + const zahl = (v, was) => assert.ok(Number.isFinite(v), `${was} ist keine Zahl`) + // Briefkasten-Zähler im Pult, Tresor-/Keller-Text in ihren Räumen + assert.ok(SCHILDER.briefkasten.left >= RAEUME.pult.x0 && SCHILDER.briefkasten.top >= RAEUME.pult.y0 - 10 && SCHILDER.briefkasten.top < RAEUME.pult.y1) + assert.ok(SCHILDER.tresor.left >= RAEUME.tresor.x0 && SCHILDER.tresor.left < RAEUME.tresor.x1 && SCHILDER.tresor.top >= RAEUME.tresor.y0 && SCHILDER.tresor.top < RAEUME.tresor.y1) + assert.ok(SCHILDER.keller.left >= RAEUME.archiv.x0 && SCHILDER.keller.left < RAEUME.archiv.x1 && SCHILDER.keller.top >= RAEUME.archiv.y0 && SCHILDER.keller.top < RAEUME.archiv.y1) + // Neon-Fläche und Antenne über dem Dach (oberhalb der Brücke) + assert.ok(SCHILDER.neon.top + SCHILDER.neon.h <= RAEUME.bruecke.y0 && SCHILDER.antenne.top + SCHILDER.antenne.h <= RAEUME.bruecke.y0) + assert.deepEqual({ ...SCHILDER.neon }, { left: 1088, top: 394, w: 224, h: 32, schrift: 22 }) + assert.deepEqual({ ...SCHILDER.antenne }, { left: 1408, top: 394, w: 8, h: 6 }) + for (const [name, s] of Object.entries(SCHILDER)) for (const [k, v] of Object.entries(s)) if (typeof v === 'number') zahl(v, `SCHILDER.${name}.${k}`) + // „nicht angeschlossen“-Zettel gehören zu Such-Räumen + assert.ok(SCHILDER.nichtAngeschlossen.breite > 0) + for (const raum of Object.keys(SCHILDER.nichtAngeschlossen.raeume)) assert.ok(SEARCH_ROOMS.includes(raum), `${raum} ist kein Such-Raum`) + // Aufzug: ein Halt je Etage, Kabine startet am ersten Halt, Seil hängt über der Kabine + assert.equal(AUFZUG.halte.length, FLOORS.length) + assert.deepEqual([...AUFZUG.halte], [...AUFZUG.halte].sort((a, b) => a - b)) + assert.equal(AUFZUG.kabine.top, AUFZUG.halte[0]) + assert.ok(AUFZUG.seil.top + AUFZUG.seil.h <= AUFZUG.kabine.top + 1 && AUFZUG.seil.top < AUFZUG.kabine.top) + ETAGEN.forEach((e, i) => assert.equal(AUFZUG.halte[i] + AUFZUG.kabine.h, e.oeffnung[1], `Halt ${e.id}: Kabinenboden bündig mit dem Boden der Öffnung`)) + // Detailansicht + assert.deepEqual({ ...DETAIL.szene }, { w: 640, h: 400 }) + assert.deepEqual(Object.keys(DETAIL.figur).sort(), ['bruecke', 'buero', 'nicht']) + for (const p of [...Object.values(DETAIL.figur), DETAIL.monitor, DETAIL.dampf, DETAIL.zzz, DETAIL.wirbel, DETAIL.schild]) { + assert.ok(p.left >= 0 && p.top >= 0 && p.left < DETAIL.szene.w && p.top < DETAIL.szene.h, `Overlay außerhalb der Szene: ${JSON.stringify(p)}`) + } + assert.ok(DETAIL.schild.w > 0 && DETAIL.schild.h > 0) +}) + +test('GRAUE_RAEUME: nur Such-Räume mit sinnvoller Lage (leer, solange das neue Haus fehlt)', () => { + assert.equal(typeof GRAUE_RAEUME, 'object') + for (const [raum, g] of Object.entries(GRAUE_RAEUME)) { + assert.ok(SEARCH_ROOMS.includes(raum), `${raum} ist kein Such-Raum`) + for (const k of ['left', 'top', 'width', 'height']) assert.ok(Number.isInteger(g[k]), `${raum}.${k}`) + const r = RAEUME[raum] + assert.ok(g.left >= HAUS_BILD.x && g.left + g.width <= HAUS_BILD.x + HAUS_BILD.w && g.top >= HAUS_BILD.y && g.top + g.height <= HAUS_BILD.y + HAUS_BILD.h, `${raum}: außerhalb des Hausbilds`) + assert.ok(g.left < r.x1 && g.left + g.width > r.x0 && g.top < r.y1 && g.top + g.height > r.y0, `${raum}: Ebene trifft den Raum nicht`) + } +})