Hustler-Pixelwelt T8: Regie (Ereignisse als Blasen, Icons, Flüge) in der Hochhaus-Welt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
94b88462c7
commit
4c5074f4f6
4 files changed
+1429
No files matched your search
@@ -0,0 +1,135 @@
|
|||||||
|
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||||
|
import { createRegie } from '../../../lib/hustlerRegie.js'
|
||||||
|
|
||||||
|
// Regie-Ebene der Hochhaus-Welt (Spec E9): zeichnet die Effekte der Regie (src/lib/hustlerRegie.js) als absolut positionierte Elemente in
|
||||||
|
// Weltkoordinaten über der Welt. Gehört als Kind in die skalierte Bühne (Wurzel mit position: absolute; inset: 0), nimmt keine Klicks an und
|
||||||
|
// ist für Bildschirmleser versteckt (der Status steht schon an den Figuren). Keine Props: die Seite spielt über das Handle
|
||||||
|
// { push(events), flush() } (regieRef) neue Ereignisse vor. Kein CSS-Import und keine Bilder: die Regeln stehen in styles/hustler/hustler-regie.css.
|
||||||
|
// Ein Zeitgeber läuft nur, solange Effekte sichtbar sind oder gleich starten (setTimeout bis zur nächsten Änderung, kein Dauer-Intervall);
|
||||||
|
// beim Abbau werden Zeitgeber und Beobachter aufgeräumt. „Weniger Bewegung“ (matchMedia, auch bei Änderung) zeigt nur Blasen,
|
||||||
|
// ein verdeckter Tab leert die Ebene (visibilitychange) und nimmt keine neuen Ereignisse an.
|
||||||
|
|
||||||
|
const WENIGER_BEWEGUNG = '(prefers-reduced-motion: reduce)'
|
||||||
|
|
||||||
|
// Fenster und Dokument nur, wo es sie gibt (Tests unter Node, Vorschau ohne Browser)
|
||||||
|
function bewegungMedium() {
|
||||||
|
try {
|
||||||
|
return typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(WENIGER_BEWEGUNG) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const verdeckt = () => typeof document !== 'undefined' && document.hidden === true
|
||||||
|
|
||||||
|
// Gleiche Effekte (nach Kennung und Reihenfolge)? Dann bleibt der Zustand unverändert und React zeichnet nicht neu.
|
||||||
|
const gleich = (a, b) => a.length === b.length && a.every((e, i) => e.id === b[i].id)
|
||||||
|
|
||||||
|
// Regie, Zeitgeber und Handle: einmal pro Ebene, gehalten in einem Ref (kein Zustand, damit Aufrufe von außen nie veraltete Werte sehen)
|
||||||
|
function baueSteuerung(setEffekte) {
|
||||||
|
const s = { regie: createRegie(), timer: null, lebt: false }
|
||||||
|
const stoppen = () => {
|
||||||
|
if (s.timer !== null) {
|
||||||
|
clearTimeout(s.timer)
|
||||||
|
s.timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Liste neu holen, zeigen und den Zeitgeber auf die nächste Änderung stellen (nur wenn etwas läuft oder wartet)
|
||||||
|
const aktualisieren = () => {
|
||||||
|
stoppen()
|
||||||
|
if (!s.lebt) return
|
||||||
|
const liste = s.regie.tick()
|
||||||
|
setEffekte((alt) => (gleich(alt, liste) ? alt : liste))
|
||||||
|
const bis = s.regie.naechsteIn()
|
||||||
|
if (bis !== null) s.timer = setTimeout(aktualisieren, Math.max(1, bis))
|
||||||
|
}
|
||||||
|
s.aktualisieren = aktualisieren
|
||||||
|
s.stoppen = stoppen
|
||||||
|
s.push = (events) => {
|
||||||
|
if (!s.lebt || verdeckt()) return
|
||||||
|
s.regie.push(events)
|
||||||
|
aktualisieren()
|
||||||
|
}
|
||||||
|
s.flush = () => {
|
||||||
|
s.regie.flush()
|
||||||
|
aktualisieren()
|
||||||
|
}
|
||||||
|
s.setReduziert = (wert) => {
|
||||||
|
s.regie.setReduziert(wert)
|
||||||
|
aktualisieren()
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lage in Weltkoordinaten (Mittelpunkt bzw. Spitze der Blase) und Dauer für die CSS
|
||||||
|
const stil = (e) => ({ left: `${e.x}px`, top: `${e.y}px`, '--dauer': `${e.dauer}ms` })
|
||||||
|
|
||||||
|
function effektKnoten(e) {
|
||||||
|
switch (e.art) {
|
||||||
|
case 'blase':
|
||||||
|
return (
|
||||||
|
<div key={e.id} className="hk-regie-blase" style={stil(e)}>
|
||||||
|
<span className="hk-regie-blase-text">{e.text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 'icon':
|
||||||
|
return <div key={e.id} className={`hk-regie-icon hk-regie-bild-${e.icon}`} style={stil(e)} />
|
||||||
|
case 'flug':
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
className={`hk-regie-flug hk-regie-bild-${e.icon}`}
|
||||||
|
style={{ ...stil(e), '--dx': `${e.x2 - e.x}px`, '--dy': `${e.y2 - e.y}px` }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'jubel':
|
||||||
|
return <div key={e.id} className={`hk-regie-jubel hk-regie-bild-${e.icon}`} style={stil(e)} />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RegieEbene = forwardRef(function RegieEbene(_props, ref) {
|
||||||
|
const [effekte, setEffekte] = useState([])
|
||||||
|
const steuerung = useRef(null)
|
||||||
|
if (steuerung.current === null) steuerung.current = baueSteuerung(setEffekte)
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
push: (events) => steuerung.current.push(events),
|
||||||
|
flush: () => steuerung.current.flush(),
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const s = steuerung.current
|
||||||
|
s.lebt = true
|
||||||
|
const medium = bewegungMedium()
|
||||||
|
const beiBewegung = () => s.setReduziert(Boolean(medium && medium.matches))
|
||||||
|
if (medium) {
|
||||||
|
s.regie.setReduziert(medium.matches)
|
||||||
|
if (typeof medium.addEventListener === 'function') medium.addEventListener('change', beiBewegung)
|
||||||
|
else if (typeof medium.addListener === 'function') medium.addListener(beiBewegung)
|
||||||
|
}
|
||||||
|
const beiSicht = () => {
|
||||||
|
if (verdeckt()) s.flush()
|
||||||
|
}
|
||||||
|
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', beiSicht)
|
||||||
|
s.aktualisieren() // nach einem erneuten Einhängen (StrictMode) steht keine alte Liste mehr da
|
||||||
|
return () => {
|
||||||
|
s.lebt = false
|
||||||
|
s.stoppen()
|
||||||
|
s.regie.flush()
|
||||||
|
if (medium) {
|
||||||
|
if (typeof medium.removeEventListener === 'function') medium.removeEventListener('change', beiBewegung)
|
||||||
|
else if (typeof medium.removeListener === 'function') medium.removeListener(beiBewegung)
|
||||||
|
}
|
||||||
|
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', beiSicht)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hk-regie" aria-hidden="true">
|
||||||
|
{effekte.map(effektKnoten)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default RegieEbene
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// Regie der Hustler-Pixelwelt (Spec E9): macht neue Ereignisse in der Hochhaus-Welt kurz sichtbar.
|
||||||
|
// Rein: kein React, kein window/document, kein Zeitgeber. Grundlage sind die Schritte aus eventToSteps (workersEvents.js); die Regie
|
||||||
|
// übersetzt sie in Effekte mit Ort (Weltkoordinaten aus hustlerWelt.js), Start und Dauer. Gezeichnet wird in RegieEbene.jsx.
|
||||||
|
// blase Sprechblase über dem Kopf (say, 2,5 s) icon Symbol neben der Figur oder am Platz (fx, 1,2 s)
|
||||||
|
// flug Umschlag oder Münze von A nach B (give, fx mit to, 0,9 s) jubel Stern-Pop neben der Figur (cheer, 1,2 s)
|
||||||
|
// task und pose erzeugen nichts (Status und Text folgen schon über reduceEvents). Die Regie läuft nach einer Uhr, die der Aufrufer
|
||||||
|
// liefert (jetzt), und hält selbst keinen Zeitgeber: tick() holt, was gerade zu sehen ist, naechsteIn() sagt, wann sich das ändert.
|
||||||
|
import { eventToSteps, SPOTS } from './workersEvents.js'
|
||||||
|
import { HUSTLER } from './hustlerWelt.js'
|
||||||
|
|
||||||
|
// Dauer je Art in Millisekunden (die CSS in hustler-regie.css läuft mit derselben Dauer über --dauer)
|
||||||
|
export const DAUER = Object.freeze({ blase: 2500, icon: 1200, flug: 900, jubel: 1200 })
|
||||||
|
|
||||||
|
// Symbol je fx am Ort (fx mit Ziel, also envelope und coin, sind Flüge)
|
||||||
|
export const ICON_NACH_FX = Object.freeze({ paper: 'akte', stampOk: 'haken', stampNo: 'stopp', drawer: 'akte', alert: 'alarm' })
|
||||||
|
|
||||||
|
// Flugsymbol je fx
|
||||||
|
const FLUG_NACH_FX = Object.freeze({ envelope: 'umschlag', coin: 'muenze' })
|
||||||
|
|
||||||
|
// Plätze ohne Figur in Weltkoordinaten (Mittelpunkt): Briefkasten, Tresor-Tür, Keller, vor dem Eingang links am Haus
|
||||||
|
export const PLAETZE = Object.freeze({
|
||||||
|
pult: Object.freeze({ x: 1240, y: 920 }),
|
||||||
|
tresor: Object.freeze({ x: 1375, y: 975 }),
|
||||||
|
archiv: Object.freeze({ x: 1250, y: 1040 }),
|
||||||
|
aussen: Object.freeze({ x: 900, y: 995 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const KOPF_ABSTAND = 36 // Spitze der Blase: so weit über dem Fußpunkt f (Kopf der Figur plus etwas Luft)
|
||||||
|
const OTTO_ZUSATZ = 8 // der Overseer ist höher (Kristall)
|
||||||
|
const KOERPER_ABSTAND = 16 // Mitte der Figur, Anfang und Ende der Flüge
|
||||||
|
const SEITE_DX = 22 // Symbole stehen rechts neben der Figur, der Jubel links
|
||||||
|
const SEITE_DY = 26 // Mittelpunkt der Symbole neben der Figur: so weit über f
|
||||||
|
|
||||||
|
const MAX_STANDARD = 4
|
||||||
|
const RUECKSTAU_STANDARD = 12
|
||||||
|
|
||||||
|
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||||
|
const isNum = (n) => typeof n === 'number' && Number.isFinite(n)
|
||||||
|
const hatFigur = (id) => typeof id === 'string' && Object.hasOwn(HUSTLER, id)
|
||||||
|
const istPlatz = (id) => typeof id === 'string' && SPOTS.includes(id) && Object.hasOwn(PLAETZE, id)
|
||||||
|
|
||||||
|
/** Punkt über dem Kopf der Figur (Spitze der Sprechblase); unbekannter Worker -> null. */
|
||||||
|
export function kopfPunkt(id) {
|
||||||
|
if (!hatFigur(id)) return null
|
||||||
|
const h = HUSTLER[id]
|
||||||
|
return { x: h.a, y: h.f - KOPF_ABSTAND - (id === 'otto' ? OTTO_ZUSATZ : 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mitte der Figur (Anfang und Ende der Flüge); unbekannter Worker -> null. */
|
||||||
|
export function koerperPunkt(id) {
|
||||||
|
if (!hatFigur(id)) return null
|
||||||
|
const h = HUSTLER[id]
|
||||||
|
return { x: h.a, y: h.f - KOERPER_ABSTAND }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mittelpunkt neben der Figur: richtung 1 rechts (Symbole), -1 links (Jubel)
|
||||||
|
function seitePunkt(id, richtung) {
|
||||||
|
if (!hatFigur(id)) return null
|
||||||
|
const h = HUSTLER[id]
|
||||||
|
return { x: h.a + richtung * SEITE_DX, y: h.f - SEITE_DY }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ort eines Schritts: Worker oder Platz -> Punkt (Flüge), sonst null
|
||||||
|
function endpunkt(id) {
|
||||||
|
if (hatFigur(id)) return koerperPunkt(id)
|
||||||
|
if (istPlatz(id)) return PLAETZE[id]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function flugEntwurf(icon, von, nach, delay) {
|
||||||
|
const a = endpunkt(von)
|
||||||
|
const b = endpunkt(nach)
|
||||||
|
if (!a || !b) return null
|
||||||
|
// Start und Ziel am selben Ort: kein Flug
|
||||||
|
if (von === nach || (a.x === b.x && a.y === b.y)) return null
|
||||||
|
return { art: 'flug', icon, x: a.x, y: a.y, x2: b.x, y2: b.y, dauer: DAUER.flug, delay: isNum(delay) && delay > 0 ? delay * 1000 : 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt (eventToSteps) -> Entwurf eines Effekts oder null (task, pose, Unbekanntes, Leeres)
|
||||||
|
function entwurfVon(step) {
|
||||||
|
if (!isRecord(step)) return null
|
||||||
|
switch (step.op) {
|
||||||
|
case 'say': {
|
||||||
|
const p = kopfPunkt(step.who)
|
||||||
|
if (!p || typeof step.text !== 'string' || step.text === '') return null
|
||||||
|
return { art: 'blase', wer: step.who, text: step.text, x: p.x, y: p.y, dauer: DAUER.blase, delay: 0 }
|
||||||
|
}
|
||||||
|
case 'give':
|
||||||
|
return flugEntwurf('umschlag', step.from, step.to, 0)
|
||||||
|
case 'cheer': {
|
||||||
|
const p = seitePunkt(step.who, -1)
|
||||||
|
return p ? { art: 'jubel', icon: 'stern', wer: step.who, x: p.x, y: p.y, dauer: DAUER.jubel, delay: 0 } : null
|
||||||
|
}
|
||||||
|
case 'fx': {
|
||||||
|
if (Object.hasOwn(FLUG_NACH_FX, step.fx)) {
|
||||||
|
return Object.hasOwn(step, 'to') ? flugEntwurf(FLUG_NACH_FX[step.fx], step.at, step.to, step.delay) : null
|
||||||
|
}
|
||||||
|
if (!Object.hasOwn(ICON_NACH_FX, step.fx)) return null
|
||||||
|
const p = hatFigur(step.at) ? seitePunkt(step.at, 1) : istPlatz(step.at) ? PLAETZE[step.at] : null
|
||||||
|
return p ? { art: 'icon', icon: ICON_NACH_FX[step.fx], x: p.x, y: p.y, dauer: DAUER.icon, delay: 0 } : null
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ganzzahl(wert, standard, min) {
|
||||||
|
return isNum(wert) && Math.floor(wert) >= min ? Math.floor(wert) : standard
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createRegie({ jetzt = () => Date.now(), max = 4, rueckstau = 12, reduziert = false })
|
||||||
|
* -> { push(events), tick(), flush(), setReduziert(bool), naechsteIn(), wartende() }
|
||||||
|
* push(events): Liste neuer Ereignisse (Reihenfolge = Alter); kaputte Eingaben, unbekannte Worker und Plätze erzeugen nichts.
|
||||||
|
* tick(): die jetzt sichtbaren Effekte { id, art, x, y, x2?, y2?, text?, icon?, wer?, start, dauer } (neues Feld je Aufruf, die Effekte
|
||||||
|
* selbst bleiben dieselben Objekte, solange sie laufen). Höchstens `max` zugleich; wartende rücken nach, sobald einer abläuft.
|
||||||
|
* Münzen mit delay werden erst zu ihrem Zeitpunkt sichtbar. Je Worker höchstens eine Blase: eine neue ersetzt die alte (auch die laufende,
|
||||||
|
* ohne einen weiteren Platz zu brauchen).
|
||||||
|
* Mehr als `rueckstau` Wartende: die ältesten fallen weg. reduziert: nur Blasen. flush(): leert Warteschlange und laufende Effekte
|
||||||
|
* (der Tab war verdeckt). naechsteIn(): Millisekunden bis sich die Liste ändert (Ablauf oder Start eines Verzögerten), null = nichts los.
|
||||||
|
* wartende(): Zahl der Wartenden (für Tests und Anzeigen).
|
||||||
|
*/
|
||||||
|
export function createRegie({ jetzt = () => Date.now(), max = MAX_STANDARD, rueckstau = RUECKSTAU_STANDARD, reduziert = false } = {}) {
|
||||||
|
const hoechstens = ganzzahl(max, MAX_STANDARD, 1)
|
||||||
|
const stau = ganzzahl(rueckstau, RUECKSTAU_STANDARD, 0)
|
||||||
|
let nurBlasen = Boolean(reduziert)
|
||||||
|
let zaehler = 0
|
||||||
|
let laufend = [] // sichtbare Effekte
|
||||||
|
let wartend = [] // Entwürfe mit bereit = frühester Start (Millisekunden der Uhr)
|
||||||
|
|
||||||
|
const uhr = () => {
|
||||||
|
const t = jetzt()
|
||||||
|
return isNum(t) ? t : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abgelaufene entfernen, bereite Wartende starten (in Reihenfolge); eine Blase ersetzt die laufende Blase desselben Workers
|
||||||
|
function fuellen(t) {
|
||||||
|
laufend = laufend.filter((e) => e.start + e.dauer > t)
|
||||||
|
let i = 0
|
||||||
|
while (i < wartend.length) {
|
||||||
|
const w = wartend[i]
|
||||||
|
if (w.bereit > t) { i += 1; continue }
|
||||||
|
const alt = w.art === 'blase' ? laufend.find((e) => e.art === 'blase' && e.wer === w.wer) : undefined
|
||||||
|
if (!alt && laufend.length >= hoechstens) { i += 1; continue }
|
||||||
|
if (alt) laufend = laufend.filter((e) => e !== alt)
|
||||||
|
wartend.splice(i, 1)
|
||||||
|
zaehler += 1
|
||||||
|
const { bereit, delay, ...rest } = w
|
||||||
|
laufend.push(Object.freeze({ id: `r${zaehler}`, ...rest, start: t }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function einreihen(entwurf, t) {
|
||||||
|
// eine neue Blase macht eine noch wartende Blase desselben Workers überflüssig
|
||||||
|
if (entwurf.art === 'blase') wartend = wartend.filter((w) => !(w.art === 'blase' && w.wer === entwurf.wer))
|
||||||
|
wartend.push({ ...entwurf, bereit: t + entwurf.delay })
|
||||||
|
fuellen(t)
|
||||||
|
// Rückstau: die ältesten Wartenden fallen weg
|
||||||
|
if (wartend.length > stau) wartend = wartend.slice(wartend.length - stau)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
push(events) {
|
||||||
|
if (!Array.isArray(events)) return
|
||||||
|
const t = uhr()
|
||||||
|
for (const event of events) {
|
||||||
|
let steps
|
||||||
|
try {
|
||||||
|
steps = eventToSteps(event)
|
||||||
|
} catch {
|
||||||
|
continue // die Regie ist Zierde und darf den Ereignisstrom nie stören
|
||||||
|
}
|
||||||
|
for (const step of steps) {
|
||||||
|
const entwurf = entwurfVon(step)
|
||||||
|
if (!entwurf || (nurBlasen && entwurf.art !== 'blase')) continue
|
||||||
|
einreihen(entwurf, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tick() {
|
||||||
|
fuellen(uhr())
|
||||||
|
return laufend.slice()
|
||||||
|
},
|
||||||
|
flush() {
|
||||||
|
laufend = []
|
||||||
|
wartend = []
|
||||||
|
},
|
||||||
|
setReduziert(wert) {
|
||||||
|
nurBlasen = Boolean(wert)
|
||||||
|
if (!nurBlasen) return
|
||||||
|
laufend = laufend.filter((e) => e.art === 'blase')
|
||||||
|
wartend = wartend.filter((w) => w.art === 'blase')
|
||||||
|
},
|
||||||
|
naechsteIn() {
|
||||||
|
const t = uhr()
|
||||||
|
let naechste = Infinity
|
||||||
|
for (const e of laufend) naechste = Math.min(naechste, e.start + e.dauer)
|
||||||
|
for (const w of wartend) if (w.bereit > t) naechste = Math.min(naechste, w.bereit)
|
||||||
|
return naechste === Infinity ? null : Math.max(0, naechste - t)
|
||||||
|
},
|
||||||
|
wartende() {
|
||||||
|
return wartend.length
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/* Regie der Hochhaus-Welt (T8, Spec E9): Sprechblase, Symbol-Pop, Flug (Umschlag, Münze) und Jubel-Stern, gezeichnet von RegieEbene.jsx.
|
||||||
|
Handgeschrieben, nicht erzeugt. Alle Klassen beginnen mit hk-regie- und stehen unter der Wurzel .hk-hochhaus; alle Keyframes heißen hk-regie-….
|
||||||
|
Die Ebene liegt in der skalierten Bühne (Weltkoordinaten, 1 Einheit = 1 Kunstpixel): left/top sind der Mittelpunkt (Blase: Spitze des Dreiecks),
|
||||||
|
--dauer (und beim Flug --dx/--dy, der Weg zum Ziel) kommen inline aus dem Effekt. Die Bilder sind die vorhandenen PNGs aus assets/hustler
|
||||||
|
(nichts Neues gezeichnet), 16 × 16 in der Welt; bei Sprite-Blättern (Münze, Stern) zeigt die Regel nur das erste Bild.
|
||||||
|
„Weniger Bewegung“: keine Animation (die Regie zeigt dann ohnehin nur Blasen). Wird in WorkersPage.jsx importiert (CSS-Importe nur dort). */
|
||||||
|
|
||||||
|
.hk-hochhaus .hk-regie {
|
||||||
|
position: absolute; inset: 0; z-index: 50;
|
||||||
|
pointer-events: none; overflow: visible;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
.hk-hochhaus .hk-regie * { pointer-events: none; }
|
||||||
|
|
||||||
|
/* ---------- Sprechblase: dunkler Pixel-Rand ohne Ecken, kleines Dreieck nach unten; die Spitze sitzt auf left/top ---------- */
|
||||||
|
.hk-hochhaus .hk-regie-blase {
|
||||||
|
position: absolute;
|
||||||
|
margin-top: -6px; /* Dreieck (2 px Rand + 4 px Spitze) hängt unter dem Kasten: die Spitze landet auf top */
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
}
|
||||||
|
.hk-hochhaus .hk-regie-blase-text {
|
||||||
|
display: block; position: relative; box-sizing: border-box;
|
||||||
|
padding: 2px 4px; white-space: nowrap;
|
||||||
|
font-family: 'Pixelify Sans', ui-monospace, monospace; font-size: 8px; line-height: 10px; font-weight: 400; letter-spacing: normal;
|
||||||
|
color: #1a1630; background: #fffbe8;
|
||||||
|
box-shadow: 0 -2px 0 0 #1a1630, 0 2px 0 0 #1a1630, -2px 0 0 0 #1a1630, 2px 0 0 0 #1a1630;
|
||||||
|
transform-origin: 50% 100%;
|
||||||
|
animation: hk-regie-blase var(--dauer, 2500ms) steps(1, end) both;
|
||||||
|
}
|
||||||
|
.hk-hochhaus .hk-regie-blase-text::after {
|
||||||
|
content: ''; position: absolute; left: 50%; top: 100%; margin: 2px 0 0 -3px;
|
||||||
|
width: 0; height: 0;
|
||||||
|
border-left: 3px solid transparent; border-right: 3px solid transparent; border-top: 4px solid #1a1630;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Symbole: Haken, Stopp, Akte, Alarm, Umschlag, Münze, Stern (Mittelpunkt auf left/top) ---------- */
|
||||||
|
.hk-hochhaus .hk-regie-icon,
|
||||||
|
.hk-hochhaus .hk-regie-flug,
|
||||||
|
.hk-hochhaus .hk-regie-jubel {
|
||||||
|
position: absolute; width: 16px; height: 16px; margin: -8px 0 0 -8px;
|
||||||
|
background-repeat: no-repeat; background-position: 0 0; background-size: auto 16px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
.hk-hochhaus .hk-regie-bild-umschlag { background-image: url(../../assets/hustler/pult/ui/art-mail.png); }
|
||||||
|
.hk-hochhaus .hk-regie-bild-haken { background-image: url(../../assets/hustler/keller/ui/haken.png); }
|
||||||
|
.hk-hochhaus .hk-regie-bild-stopp { background-image: url(../../assets/hustler/keller/ui/stopp.png); }
|
||||||
|
.hk-hochhaus .hk-regie-bild-akte { background-image: url(../../assets/hustler/keller/ui/akte-icon.png); }
|
||||||
|
.hk-hochhaus .hk-regie-bild-alarm { background-image: url(../../assets/hustler/pult/ui/art-beschwerde.png); }
|
||||||
|
.hk-hochhaus .hk-regie-bild-muenze { background-image: url(../../assets/hustler/ui/muenze-gruen.png); }
|
||||||
|
.hk-hochhaus .hk-regie-bild-stern { background-image: url(../../assets/hustler/ui/stern.png); }
|
||||||
|
|
||||||
|
/* Icon-Pop: kurz aufploppen, stehen, am Ende ausblenden */
|
||||||
|
.hk-hochhaus .hk-regie-icon { animation: hk-regie-pop var(--dauer, 1200ms) steps(1, end) both; }
|
||||||
|
|
||||||
|
/* Flug: Bogen von der Mitte des Absenders zum Ziel (--dx/--dy), fünf Stützpunkte auf einer Parabel, 14 px Bogenhöhe */
|
||||||
|
.hk-hochhaus .hk-regie-flug { animation: hk-regie-flug var(--dauer, 900ms) linear both; }
|
||||||
|
|
||||||
|
/* Jubel: Stern ploppt auf und hüpft */
|
||||||
|
.hk-hochhaus .hk-regie-jubel { animation: hk-regie-jubel var(--dauer, 1200ms) steps(1, end) both; }
|
||||||
|
|
||||||
|
@keyframes hk-regie-blase {
|
||||||
|
0% { transform: scale(0.5); opacity: 1; }
|
||||||
|
4% { transform: scale(1.12); opacity: 1; }
|
||||||
|
8% { transform: scale(1); opacity: 1; }
|
||||||
|
90% { transform: scale(1); opacity: 0.6; }
|
||||||
|
96% { transform: scale(1); opacity: 0.3; }
|
||||||
|
100% { transform: scale(1); opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes hk-regie-pop {
|
||||||
|
0% { transform: scale(0.4); opacity: 1; }
|
||||||
|
10% { transform: scale(1.35); opacity: 1; }
|
||||||
|
22% { transform: scale(1); opacity: 1; }
|
||||||
|
85% { transform: scale(1); opacity: 0.5; }
|
||||||
|
100% { transform: scale(1); opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes hk-regie-flug {
|
||||||
|
0% { transform: translate(0, 0) scale(0.7); opacity: 1; }
|
||||||
|
25% { transform: translate(calc(var(--dx, 0px) * 0.25), calc(var(--dy, 0px) * 0.25 - 10.5px)) scale(1); opacity: 1; }
|
||||||
|
50% { transform: translate(calc(var(--dx, 0px) * 0.5), calc(var(--dy, 0px) * 0.5 - 14px)) scale(1); opacity: 1; }
|
||||||
|
75% { transform: translate(calc(var(--dx, 0px) * 0.75), calc(var(--dy, 0px) * 0.75 - 10.5px)) scale(1); opacity: 1; }
|
||||||
|
100% { transform: translate(var(--dx, 0px), var(--dy, 0px)) scale(0.8); opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes hk-regie-jubel {
|
||||||
|
0% { transform: translateY(0) scale(0.4); opacity: 1; }
|
||||||
|
12% { transform: translateY(-4px) scale(1.4); opacity: 1; }
|
||||||
|
24% { transform: translateY(-8px) scale(1); opacity: 1; }
|
||||||
|
40% { transform: translateY(-3px) scale(1); opacity: 1; }
|
||||||
|
56% { transform: translateY(-8px) scale(1); opacity: 1; }
|
||||||
|
72% { transform: translateY(-3px) scale(1); opacity: 1; }
|
||||||
|
88% { transform: translateY(-8px) scale(1); opacity: 0.6; }
|
||||||
|
100% { transform: translateY(-8px) scale(1); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hk-hochhaus .hk-regie-blase-text,
|
||||||
|
.hk-hochhaus .hk-regie-icon,
|
||||||
|
.hk-hochhaus .hk-regie-flug,
|
||||||
|
.hk-hochhaus .hk-regie-jubel { animation: none; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,990 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import esbuild from 'esbuild'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
import { FAKE_REACT } from './helpers/fakeReact.mjs'
|
||||||
|
import { ev, IDS } from './helpers/workersFixtures.mjs'
|
||||||
|
|
||||||
|
// Regie der Hustler-Pixelwelt (T8, Spec E9):
|
||||||
|
// 1. src/lib/hustlerRegie.js: Ereignisse -> Effekte (Blase, Symbol, Flug, Jubel), Höchstzahl, Rückstau, Zeitablauf mit eigener Uhr, reduziert, flush, kaputte Eingaben.
|
||||||
|
// 2. RegieEbene.jsx im Mini-Läufer anstelle von React (helpers/fakeReact.mjs, um forwardRef und useImperativeHandle ergänzt), mit eigenem Zeitgeber,
|
||||||
|
// eigener Uhr, matchMedia und document.
|
||||||
|
// 3. hustler-regie.css: Bilder vorhanden, Klassen, Keyframes, „weniger Bewegung“.
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||||
|
const R = await loadModule('src/lib/hustlerRegie.js')
|
||||||
|
const E = await loadModule('src/lib/workersEvents.js')
|
||||||
|
const W = await loadModule('src/lib/hustlerWelt.js')
|
||||||
|
const { createRegie, PLAETZE, DAUER, ICON_NACH_FX, kopfPunkt, koerperPunkt } = R
|
||||||
|
const { EVENT_KINDS } = E
|
||||||
|
const { HUSTLER } = W
|
||||||
|
|
||||||
|
// ---------- Hilfen ----------
|
||||||
|
|
||||||
|
// Eigene Uhr: jetzt() liefert den Stand, vor(ms) stellt ihn weiter
|
||||||
|
function uhrVon(start = 1_000_000) {
|
||||||
|
let t = start
|
||||||
|
return { jetzt: () => t, vor: (ms) => { t += ms }, stand: () => t }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Effekte, die im Lauf von `bis` Millisekunden einmal sichtbar waren (Schritt 100 ms), in der Reihenfolge ihres Erscheinens
|
||||||
|
function sammeln(regie, uhr, bis = 8000, schritt = 100) {
|
||||||
|
const gesehen = new Map()
|
||||||
|
for (let ms = 0; ms <= bis; ms += schritt) {
|
||||||
|
for (const e of regie.tick()) if (!gesehen.has(e.id)) gesehen.set(e.id, e)
|
||||||
|
uhr.vor(schritt)
|
||||||
|
}
|
||||||
|
return [...gesehen.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kurzform eines Effekts für Vergleiche: art[:icon][:wer:text]
|
||||||
|
const kurz = (e) => (e.art === 'blase' ? `blase:${e.wer}:${e.text}` : e.icon ? `${e.art}:${e.icon}` : e.art)
|
||||||
|
|
||||||
|
const ARTEN = new Set(['blase', 'icon', 'flug', 'jubel'])
|
||||||
|
function pruefeForm(e) {
|
||||||
|
assert.ok(ARTEN.has(e.art), `art ${e.art}`)
|
||||||
|
assert.equal(typeof e.id, 'string')
|
||||||
|
assert.ok(Number.isFinite(e.x) && Number.isFinite(e.y), 'x/y')
|
||||||
|
assert.ok(Number.isFinite(e.start) && e.dauer > 0, 'start/dauer')
|
||||||
|
assert.equal(e.dauer, DAUER[e.art])
|
||||||
|
if (e.art === 'flug') assert.ok(Number.isFinite(e.x2) && Number.isFinite(e.y2), 'x2/y2')
|
||||||
|
else assert.ok(!('x2' in e) && !('y2' in e), 'x2/y2 nur beim Flug')
|
||||||
|
if (e.art === 'blase') assert.ok(typeof e.text === 'string' && e.text !== '')
|
||||||
|
assert.ok(Object.isFrozen(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Plätze und Punkte ----------
|
||||||
|
|
||||||
|
test('PLAETZE: vier Plätze in Weltkoordinaten (Briefkasten, Tresor-Tür, Keller, vor dem Eingang links), eingefroren', () => {
|
||||||
|
assert.deepEqual(Object.keys(PLAETZE).sort(), ['archiv', 'aussen', 'pult', 'tresor'])
|
||||||
|
assert.deepEqual({ ...PLAETZE.pult }, { x: 1240, y: 920 })
|
||||||
|
assert.deepEqual({ ...PLAETZE.tresor }, { x: 1375, y: 975 })
|
||||||
|
assert.deepEqual({ ...PLAETZE.archiv }, { x: 1250, y: 1040 })
|
||||||
|
assert.deepEqual({ ...PLAETZE.aussen }, { x: 900, y: 995 })
|
||||||
|
assert.ok(Object.isFrozen(PLAETZE))
|
||||||
|
for (const p of Object.values(PLAETZE)) assert.ok(Object.isFrozen(p))
|
||||||
|
assert.ok(PLAETZE.aussen.x < W.HAUS_BILD.x, 'aussen liegt links vom Haus')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Figurpunkte: Kopf = Anker a, f − 36 (Overseer höher), Körper = f − 16; unbekannte Kennungen -> null', () => {
|
||||||
|
assert.equal(IDS.length, 19)
|
||||||
|
for (const id of IDS) {
|
||||||
|
const h = HUSTLER[id]
|
||||||
|
assert.ok(h, `${id} fehlt in HUSTLER`)
|
||||||
|
assert.deepEqual(kopfPunkt(id), { x: h.a, y: id === 'otto' ? h.f - 44 : h.f - 36 }, id)
|
||||||
|
assert.deepEqual(koerperPunkt(id), { x: h.a, y: h.f - 16 }, id)
|
||||||
|
}
|
||||||
|
assert.ok(kopfPunkt('otto').y < HUSTLER.otto.f - 36, 'Overseer: Blase höher')
|
||||||
|
for (const id of ['niemand', '', 'constructor', '__proto__', 'toString', 'pult', 'system', null, undefined, 5, {}]) {
|
||||||
|
assert.equal(kopfPunkt(id), null, String(id))
|
||||||
|
assert.equal(koerperPunkt(id), null, String(id))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- Zuordnung je Ereignisart ----------
|
||||||
|
|
||||||
|
// Ein Ereignis je Art (alle 26 aus EVENT_KINDS) mit den erwarteten Effekten, unabhängig vom Code unter Test aus Übersicht §7 abgeleitet
|
||||||
|
const TABELLE = {
|
||||||
|
auftrag: { w: 'lea', extra: { to: 'max' }, soll: ['blase:lea:Max, los!', 'blase:max:Verstanden'] },
|
||||||
|
leerlauf: { w: 'lea', soll: [] },
|
||||||
|
gelesen: { w: 'lea', extra: { meta: { count: 3 } }, soll: ['blase:lea:3 gelesen'] },
|
||||||
|
fund: { w: 'lea', soll: ['blase:lea:Neuer Fund!', 'icon:akte'] },
|
||||||
|
doppelt: { w: 'lea', soll: ['blase:lea:Schon bekannt', 'icon:stopp'] },
|
||||||
|
uebergabe: { w: 'lea', extra: { to: 'bea' }, soll: ['flug:umschlag'] },
|
||||||
|
bewertet: { w: 'bea', extra: { meta: { score: 82 } }, soll: ['blase:bea:82 Punkte'] },
|
||||||
|
top3: { w: 'bea', soll: ['blase:bea:Top 3!', 'icon:haken'] },
|
||||||
|
botlead: { w: 'arne', soll: ['blase:arne:Abgelegt', 'icon:akte'] },
|
||||||
|
entwurf: { w: 'clara', soll: ['blase:clara:Entwurf fertig'] },
|
||||||
|
pult: { w: 'clara', soll: ['blase:clara:Zur Freigabe', 'flug:umschlag'] },
|
||||||
|
gesendet: { w: 'max', soll: ['blase:max:Raus damit!', 'flug:umschlag'] },
|
||||||
|
eingang: { w: 'nora', soll: ['blase:nora:Antwort!', 'flug:umschlag'] },
|
||||||
|
sortiert: { w: 'nora', extra: { meta: { classification: 'ja' } }, soll: ['blase:nora:Ja!', 'icon:haken'] },
|
||||||
|
bau_start: { w: 'bob', soll: ['blase:bob:Los geht’s'] },
|
||||||
|
bau_fertig: { w: 'bob', soll: ['blase:bob:Fertig!', 'flug:umschlag'] },
|
||||||
|
pruefung_zurueck: { w: 'paul', extra: { to: 'bob', meta: { round: 2 } }, soll: ['blase:paul:Runde 2 zurück', 'flug:umschlag', 'icon:stopp'] },
|
||||||
|
pruefung_ok: { w: 'paul', soll: ['blase:paul:Passt!', 'icon:haken'] },
|
||||||
|
vorschau_link: { w: 'bob', soll: ['blase:bob:Link raus', 'flug:umschlag'] },
|
||||||
|
angebot: { w: 'theo', soll: ['blase:theo:Kauf-Link!', 'icon:akte'] },
|
||||||
|
deal: { w: 'otto', soll: ['blase:otto:Deal! Super!', 'flug:muenze', 'flug:muenze', 'flug:muenze', 'jubel:stern'] },
|
||||||
|
gesperrt: { w: 'system', soll: ['blase:arne:Gesperrt', 'icon:stopp'] },
|
||||||
|
budget_warnung: { w: 'otto', soll: ['blase:otto:Budget 80 %', 'icon:alarm'] },
|
||||||
|
budget_stopp: { w: 'otto', soll: ['blase:otto:Budget leer', 'icon:alarm'] },
|
||||||
|
pause: { w: 'system', soll: [] },
|
||||||
|
fehler: { w: 'lea', soll: ['blase:lea:Fehler', 'icon:alarm'] },
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Zuordnung: die Tabelle deckt genau die 26 Ereignisarten ab', () => {
|
||||||
|
assert.equal(EVENT_KINDS.length, 26)
|
||||||
|
assert.deepEqual(Object.keys(TABELLE).sort(), [...EVENT_KINDS].sort())
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const kind of EVENT_KINDS) {
|
||||||
|
test(`Zuordnung: ${kind} läuft ohne Fehler und ergibt genau die erwarteten Effekte (wohlgeformt, Münzen mit Versatz)`, () => {
|
||||||
|
const { w, extra, soll } = TABELLE[kind]
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt, max: 50, rueckstau: 50 })
|
||||||
|
r.push([ev(kind, w, extra)])
|
||||||
|
const effekte = sammeln(r, uhr)
|
||||||
|
effekte.forEach(pruefeForm)
|
||||||
|
assert.deepEqual(effekte.map(kurz).sort(), [...soll].sort())
|
||||||
|
assert.equal(r.wartende(), 0)
|
||||||
|
uhr.vor(10_000)
|
||||||
|
assert.deepEqual(r.tick(), [])
|
||||||
|
assert.equal(r.naechsteIn(), null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('task und pose erzeugen nichts: leerlauf und pause bleiben leer, auch direkt nach dem Push', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt })
|
||||||
|
r.push([ev('leerlauf', 'lea'), ev('pause', 'system')])
|
||||||
|
assert.deepEqual(r.tick(), [])
|
||||||
|
assert.equal(r.naechsteIn(), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('say: Sprechblase 2,5 s an der Spitze über dem Kopf des Workers, Text so lang wie im Schritt (höchstens 20 Zeichen)', () => {
|
||||||
|
const uhr = uhrVon(42_000)
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt })
|
||||||
|
r.push([ev('gelesen', 'lea', { meta: { count: 3 } })])
|
||||||
|
const [b, ...rest] = r.tick()
|
||||||
|
assert.equal(rest.length, 0)
|
||||||
|
assert.equal(b.art, 'blase'); assert.equal(b.wer, 'lea'); assert.equal(b.text, '3 gelesen')
|
||||||
|
assert.deepEqual([b.x, b.y], [HUSTLER.lea.a, HUSTLER.lea.f - 36])
|
||||||
|
assert.equal(b.start, 42_000); assert.equal(b.dauer, 2500)
|
||||||
|
assert.ok(b.text.length <= 20)
|
||||||
|
// Overseer: Blase höher als bei den anderen
|
||||||
|
r.push([ev('budget_stopp', 'otto')])
|
||||||
|
const otto = r.tick().find((e) => e.art === 'blase' && e.wer === 'otto')
|
||||||
|
assert.deepEqual([otto.x, otto.y], [HUSTLER.otto.a, HUSTLER.otto.f - 44])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fund: Blase „Neuer Fund!“ am Kopf plus Symbol Akte neben der Figur (1,2 s)', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt })
|
||||||
|
r.push([ev('fund', 'ali')])
|
||||||
|
const effekte = r.tick()
|
||||||
|
assert.equal(effekte.length, 2)
|
||||||
|
const blase = effekte.find((e) => e.art === 'blase')
|
||||||
|
const icon = effekte.find((e) => e.art === 'icon')
|
||||||
|
assert.equal(blase.text, 'Neuer Fund!'); assert.deepEqual([blase.x, blase.y], [HUSTLER.ali.a, HUSTLER.ali.f - 36])
|
||||||
|
assert.equal(icon.icon, 'akte'); assert.equal(icon.dauer, 1200)
|
||||||
|
// das Symbol sitzt neben der Figur (rechts), nicht auf der Blase
|
||||||
|
assert.ok(icon.x > HUSTLER.ali.a && icon.x < HUSTLER.ali.a + 40)
|
||||||
|
assert.ok(icon.y > blase.y && icon.y < HUSTLER.ali.f)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fx am Ort: Symbole paper/drawer -> akte, stampOk -> haken, stampNo -> stopp, alert -> alarm (ICON_NACH_FX)', () => {
|
||||||
|
assert.deepEqual({ ...ICON_NACH_FX }, { paper: 'akte', stampOk: 'haken', stampNo: 'stopp', drawer: 'akte', alert: 'alarm' })
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt, max: 50, rueckstau: 50 })
|
||||||
|
r.push([ev('fund', 'lea'), ev('top3', 'bea'), ev('doppelt', 'sven'), ev('botlead', 'arne'), ev('fehler', 'kim')])
|
||||||
|
const icons = r.tick().filter((e) => e.art === 'icon')
|
||||||
|
assert.deepEqual(icons.map((e) => e.icon), ['akte', 'haken', 'stopp', 'akte', 'alarm'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('gesendet: Umschlag fliegt 900 ms von Max nach „aussen“; von system (Mensch am Pult) vom Briefkasten nach „aussen“', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt })
|
||||||
|
r.push([ev('gesendet', 'max')])
|
||||||
|
const flug = r.tick().find((e) => e.art === 'flug')
|
||||||
|
assert.equal(flug.icon, 'umschlag'); assert.equal(flug.dauer, 900)
|
||||||
|
assert.deepEqual([flug.x, flug.y], [HUSTLER.max.a, HUSTLER.max.f - 16])
|
||||||
|
assert.deepEqual([flug.x2, flug.y2], [PLAETZE.aussen.x, PLAETZE.aussen.y])
|
||||||
|
r.flush()
|
||||||
|
r.push([ev('gesendet', 'system')])
|
||||||
|
const alle = r.tick()
|
||||||
|
assert.deepEqual(alle.map(kurz), ['flug:umschlag'])
|
||||||
|
assert.deepEqual([alle[0].x, alle[0].y, alle[0].x2, alle[0].y2], [PLAETZE.pult.x, PLAETZE.pult.y, PLAETZE.aussen.x, PLAETZE.aussen.y])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('give und fx mit Ziel: Flüge zwischen Figuren, zum Briefkasten und von draußen zum Worker; gleicher Start und Ziel fliegt nicht', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt, max: 50, rueckstau: 50 })
|
||||||
|
r.push([ev('uebergabe', 'lea', { to: 'bea' }), ev('pult', 'clara'), ev('eingang', 'nora'), ev('uebergabe', 'lea', { to: 'lea' })])
|
||||||
|
const fluege = r.tick().filter((e) => e.art === 'flug')
|
||||||
|
assert.equal(fluege.length, 3)
|
||||||
|
const koerper = (id) => [HUSTLER[id].a, HUSTLER[id].f - 16]
|
||||||
|
assert.deepEqual([fluege[0].x, fluege[0].y, fluege[0].x2, fluege[0].y2], [...koerper('lea'), ...koerper('bea')])
|
||||||
|
assert.deepEqual([fluege[1].x, fluege[1].y, fluege[1].x2, fluege[1].y2], [...koerper('clara'), PLAETZE.pult.x, PLAETZE.pult.y])
|
||||||
|
assert.deepEqual([fluege[2].x, fluege[2].y, fluege[2].x2, fluege[2].y2], [PLAETZE.aussen.x, PLAETZE.aussen.y, ...koerper('nora')])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deal: drei Münzflüge von „aussen“ zum Tresor mit 0,4 s Versatz, Jubel-Stern und Blase beim Overseer', () => {
|
||||||
|
const uhr = uhrVon(10_000)
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt, max: 8 })
|
||||||
|
r.push([ev('deal', 'otto')])
|
||||||
|
// sofort: erste Münze, Jubel, Blase; die anderen Münzen sind noch nicht dran
|
||||||
|
assert.deepEqual(r.tick().map(kurz).sort(), ['blase:otto:Deal! Super!', 'flug:muenze', 'jubel:stern'])
|
||||||
|
assert.equal(r.wartende(), 2)
|
||||||
|
assert.equal(r.naechsteIn(), 400)
|
||||||
|
uhr.vor(399)
|
||||||
|
assert.equal(r.tick().filter((e) => e.icon === 'muenze').length, 1)
|
||||||
|
uhr.vor(1)
|
||||||
|
assert.equal(r.tick().filter((e) => e.icon === 'muenze').length, 2)
|
||||||
|
uhr.vor(400)
|
||||||
|
const muenzen = r.tick().filter((e) => e.icon === 'muenze')
|
||||||
|
assert.equal(muenzen.length, 3)
|
||||||
|
assert.deepEqual(muenzen.map((m) => m.start), [10_000, 10_400, 10_800], 'Versatz 400 ms')
|
||||||
|
for (const m of muenzen) {
|
||||||
|
assert.deepEqual([m.x, m.y, m.x2, m.y2], [PLAETZE.aussen.x, PLAETZE.aussen.y, PLAETZE.tresor.x, PLAETZE.tresor.y])
|
||||||
|
assert.equal(m.dauer, 900)
|
||||||
|
}
|
||||||
|
const jubel = r.tick().find((e) => e.art === 'jubel')
|
||||||
|
assert.equal(jubel.wer, 'otto'); assert.equal(jubel.icon, 'stern'); assert.equal(jubel.dauer, 1200)
|
||||||
|
assert.ok(Math.abs(jubel.x - HUSTLER.otto.a) <= 40 && jubel.y < HUSTLER.otto.f && jubel.y > HUSTLER.otto.f - 40, 'Stern neben dem Overseer')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deal mit der Höchstzahl 4: die dritte Münze wartet, bis die erste (900 ms) im Ziel ist', () => {
|
||||||
|
const uhr = uhrVon(10_000)
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt })
|
||||||
|
r.push([ev('deal', 'otto')])
|
||||||
|
r.tick()
|
||||||
|
uhr.vor(800)
|
||||||
|
assert.equal(r.tick().filter((e) => e.icon === 'muenze').length, 2)
|
||||||
|
assert.equal(r.tick().length, 4)
|
||||||
|
uhr.vor(100)
|
||||||
|
const muenzen = r.tick().filter((e) => e.icon === 'muenze')
|
||||||
|
assert.equal(muenzen.length, 2) // erste weg, dritte da
|
||||||
|
assert.equal(muenzen[1].start, 10_900)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- Höchstzahl, Rückstau, Zeitablauf ----------
|
||||||
|
|
||||||
|
test('Höchstzahl: nie mehr als max zugleich sichtbar (Standard 4), wartende rücken nach, sobald einer abläuft', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt })
|
||||||
|
r.push(IDS.slice(0, 6).map((id) => ev('fund', id))) // 12 Entwürfe
|
||||||
|
let gesehen = 0
|
||||||
|
for (let ms = 0; ms <= 20_000; ms += 50) {
|
||||||
|
const jetzt = r.tick()
|
||||||
|
assert.ok(jetzt.length <= 4, `${jetzt.length} bei ${ms} ms`)
|
||||||
|
gesehen = Math.max(gesehen, jetzt.length)
|
||||||
|
uhr.vor(50)
|
||||||
|
}
|
||||||
|
assert.equal(gesehen, 4)
|
||||||
|
assert.deepEqual(r.tick(), [])
|
||||||
|
assert.equal(r.wartende(), 0)
|
||||||
|
|
||||||
|
// eine andere Höchstzahl
|
||||||
|
const r2 = createRegie({ jetzt: uhr.jetzt, max: 2 })
|
||||||
|
r2.push(IDS.slice(0, 5).map((id) => ev('fund', id)))
|
||||||
|
assert.equal(r2.tick().length, 2)
|
||||||
|
// erster Ablauf: Symbole nach 1,2 s, dann rücken die nächsten nach (Start = Zeitpunkt des Ablaufs)
|
||||||
|
const anfang = uhr.stand()
|
||||||
|
uhr.vor(1200)
|
||||||
|
const danach = r2.tick()
|
||||||
|
assert.equal(danach.length, 2)
|
||||||
|
assert.ok(danach.some((e) => e.start === anfang + 1200))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Reihenfolge der Wartenden: wer zuerst kam, startet zuerst (max 1)', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt, max: 1 })
|
||||||
|
r.push(['kim', 'pia', 'finn'].map((id, i) => ev('gelesen', id, { meta: { count: i + 1 } })))
|
||||||
|
const texte = []
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
texte.push(...r.tick().map((e) => e.text))
|
||||||
|
uhr.vor(2500)
|
||||||
|
}
|
||||||
|
assert.deepEqual(texte, ['1 gelesen', '2 gelesen', '3 gelesen'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Rückstau: mehr als 12 Wartende -> die ältesten fallen weg, die neuesten bleiben (19 Blasen: 1-4 laufen, 5-7 fallen weg, 8-19 warten)', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt })
|
||||||
|
r.push(IDS.map((id, i) => ev('gelesen', id, { meta: { count: i + 1 } })))
|
||||||
|
assert.equal(r.tick().length, 4)
|
||||||
|
assert.equal(r.wartende(), 12)
|
||||||
|
const effekte = sammeln(r, uhr, 20_000, 50)
|
||||||
|
const zahlen = effekte.map((e) => Number.parseInt(e.text, 10))
|
||||||
|
assert.deepEqual(zahlen, [1, 2, 3, 4, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
|
||||||
|
assert.equal(r.wartende(), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Rückstau einstellbar: max 1, rueckstau 3, acht Blasen -> 1 läuft, 2-5 fallen weg, danach 6, 7, 8', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt, max: 1, rueckstau: 3 })
|
||||||
|
r.push(IDS.slice(0, 8).map((id, i) => ev('gelesen', id, { meta: { count: i + 1 } })))
|
||||||
|
assert.equal(r.wartende(), 3)
|
||||||
|
const texte = []
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
texte.push(...r.tick().map((e) => e.text))
|
||||||
|
uhr.vor(2500)
|
||||||
|
}
|
||||||
|
assert.deepEqual(texte, ['1 gelesen', '6 gelesen', '7 gelesen', '8 gelesen'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Rückstau 0: was nicht sofort starten kann, fällt weg', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt, max: 1, rueckstau: 0 })
|
||||||
|
r.push(IDS.slice(0, 3).map((id, i) => ev('gelesen', id, { meta: { count: i + 1 } })))
|
||||||
|
assert.equal(r.wartende(), 0)
|
||||||
|
assert.deepEqual(r.tick().map((e) => e.text), ['1 gelesen'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Zeitablauf mit eigener Uhr: Blase 2500 ms, Symbol 1200 ms, Flug 900 ms; naechsteIn zählt bis zum nächsten Ablauf', () => {
|
||||||
|
const uhr = uhrVon(5000)
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt })
|
||||||
|
r.push([ev('fund', 'lea'), ev('gesendet', 'max')])
|
||||||
|
assert.equal(r.tick().length, 4) // Blase + Akte (lea), Blase + Flug (max)
|
||||||
|
assert.equal(r.naechsteIn(), 900)
|
||||||
|
uhr.vor(899)
|
||||||
|
assert.equal(r.tick().length, 4)
|
||||||
|
assert.equal(r.naechsteIn(), 1)
|
||||||
|
uhr.vor(1)
|
||||||
|
assert.deepEqual(r.tick().map(kurz).sort(), ['blase:lea:Neuer Fund!', 'blase:max:Raus damit!', 'icon:akte'])
|
||||||
|
assert.equal(r.naechsteIn(), 300)
|
||||||
|
uhr.vor(300)
|
||||||
|
assert.deepEqual(r.tick().map(kurz).sort(), ['blase:lea:Neuer Fund!', 'blase:max:Raus damit!'])
|
||||||
|
uhr.vor(1299)
|
||||||
|
assert.equal(r.tick().length, 2)
|
||||||
|
uhr.vor(1)
|
||||||
|
assert.deepEqual(r.tick(), [])
|
||||||
|
assert.equal(r.naechsteIn(), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('tick: neues Feld je Aufruf, dieselben (eingefrorenen) Effekte, solange sie laufen; start bleibt', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt })
|
||||||
|
r.push([ev('fund', 'lea')])
|
||||||
|
const a = r.tick()
|
||||||
|
uhr.vor(500)
|
||||||
|
const b = r.tick()
|
||||||
|
assert.notEqual(a, b)
|
||||||
|
assert.equal(a[0], b[0])
|
||||||
|
assert.equal(b[0].start, 1_000_000)
|
||||||
|
a.pop()
|
||||||
|
assert.equal(r.tick().length, 2, 'das Feld gehört dem Aufrufer')
|
||||||
|
assert.equal(new Set(r.tick().map((e) => e.id)).size, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Standarduhr: ohne jetzt wird Date.now benutzt', () => {
|
||||||
|
const r = createRegie()
|
||||||
|
const vorher = Date.now()
|
||||||
|
r.push([ev('fund', 'lea')])
|
||||||
|
const [e] = r.tick()
|
||||||
|
assert.ok(e.start >= vorher && e.start <= Date.now())
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- Eine Blase je Worker ----------
|
||||||
|
|
||||||
|
test('eine Blase je Worker: die neue ersetzt die laufende, auch wenn alle Plätze belegt sind', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt, max: 1 })
|
||||||
|
r.push([ev('gelesen', 'lea', { meta: { count: 1 } })])
|
||||||
|
assert.deepEqual(r.tick().map((e) => e.text), ['1 gelesen'])
|
||||||
|
uhr.vor(500)
|
||||||
|
r.push([ev('gelesen', 'lea', { meta: { count: 2 } })])
|
||||||
|
assert.deepEqual(r.tick().map((e) => e.text), ['2 gelesen'])
|
||||||
|
assert.equal(r.wartende(), 0)
|
||||||
|
assert.equal(r.tick()[0].start, uhr.stand())
|
||||||
|
// andere Worker haben eigene Blasen
|
||||||
|
const r2 = createRegie({ jetzt: uhr.jetzt })
|
||||||
|
r2.push([ev('gelesen', 'lea', { meta: { count: 1 } }), ev('gelesen', 'sven', { meta: { count: 2 } }), ev('gelesen', 'lea', { meta: { count: 3 } })])
|
||||||
|
assert.deepEqual(r2.tick().map((e) => `${e.wer}:${e.text}`).sort(), ['lea:3 gelesen', 'sven:2 gelesen'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('eine Blase je Worker: eine noch wartende Blase wird von der nächsten desselben Workers ersetzt', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt, max: 1 })
|
||||||
|
r.push([ev('gelesen', 'kim', { meta: { count: 1 } })]) // läuft
|
||||||
|
r.push([ev('gelesen', 'lea', { meta: { count: 2 } })]) // wartet
|
||||||
|
r.push([ev('gelesen', 'lea', { meta: { count: 3 } })]) // ersetzt die wartende
|
||||||
|
assert.equal(r.wartende(), 1)
|
||||||
|
const texte = []
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
texte.push(...r.tick().map((e) => e.text))
|
||||||
|
uhr.vor(2500)
|
||||||
|
}
|
||||||
|
assert.deepEqual(texte, ['1 gelesen', '3 gelesen'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Blasen ersetzen nur Blasen: Symbole und Flüge desselben Workers laufen weiter', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt })
|
||||||
|
r.push([ev('fund', 'lea')])
|
||||||
|
uhr.vor(300)
|
||||||
|
r.push([ev('gelesen', 'lea', { meta: { count: 9 } })])
|
||||||
|
assert.deepEqual(r.tick().map(kurz).sort(), ['blase:lea:9 gelesen', 'icon:akte'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- reduziert, flush ----------
|
||||||
|
|
||||||
|
test('reduziert: nur Blasen, alles andere (Symbole, Flüge, Jubel) fällt weg', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt, reduziert: true })
|
||||||
|
r.push([ev('fund', 'lea'), ev('gesendet', 'max'), ev('deal', 'otto'), ev('top3', 'bea')])
|
||||||
|
const effekte = sammeln(r, uhr)
|
||||||
|
assert.deepEqual(effekte.map(kurz).sort(), ['blase:bea:Top 3!', 'blase:lea:Neuer Fund!', 'blase:max:Raus damit!', 'blase:otto:Deal! Super!'])
|
||||||
|
assert.equal(r.wartende(), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setReduziert: schaltet zur Laufzeit um, entfernt laufende und wartende Nicht-Blasen sofort; danach wieder alles', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt })
|
||||||
|
r.push([ev('deal', 'otto')])
|
||||||
|
assert.equal(r.tick().length, 3)
|
||||||
|
assert.equal(r.wartende(), 2)
|
||||||
|
r.setReduziert(true)
|
||||||
|
assert.deepEqual(r.tick().map(kurz), ['blase:otto:Deal! Super!'])
|
||||||
|
assert.equal(r.wartende(), 0)
|
||||||
|
assert.equal(r.naechsteIn(), 2500)
|
||||||
|
r.push([ev('fund', 'lea')])
|
||||||
|
assert.deepEqual(r.tick().map(kurz).sort(), ['blase:lea:Neuer Fund!', 'blase:otto:Deal! Super!'])
|
||||||
|
r.setReduziert(false)
|
||||||
|
r.push([ev('top3', 'bea')])
|
||||||
|
assert.ok(r.tick().some((e) => e.art === 'icon' && e.icon === 'haken'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('flush: leert Warteschlange und laufende Effekte; danach geht es normal weiter', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt, max: 2 })
|
||||||
|
r.push(IDS.slice(0, 6).map((id) => ev('fund', id)))
|
||||||
|
assert.equal(r.tick().length, 2)
|
||||||
|
assert.ok(r.wartende() > 0)
|
||||||
|
r.flush()
|
||||||
|
assert.deepEqual(r.tick(), [])
|
||||||
|
assert.equal(r.wartende(), 0)
|
||||||
|
assert.equal(r.naechsteIn(), null)
|
||||||
|
uhr.vor(60_000)
|
||||||
|
assert.deepEqual(r.tick(), [])
|
||||||
|
r.push([ev('top3', 'bea')])
|
||||||
|
assert.equal(r.tick().length, 2)
|
||||||
|
// Kennungen bleiben auch nach flush eindeutig
|
||||||
|
const ids = r.tick().map((e) => e.id)
|
||||||
|
r.flush()
|
||||||
|
r.push([ev('top3', 'bea')])
|
||||||
|
assert.ok(r.tick().every((e) => !ids.includes(e.id)))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('naechsteIn: null im Leerlauf, Rest der Laufzeit, Beginn der nächsten verzögerten Münze', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt })
|
||||||
|
assert.equal(r.naechsteIn(), null)
|
||||||
|
r.push([ev('gelesen', 'lea')])
|
||||||
|
assert.equal(r.naechsteIn(), 2500)
|
||||||
|
uhr.vor(1000)
|
||||||
|
assert.equal(r.naechsteIn(), 1500)
|
||||||
|
uhr.vor(1500)
|
||||||
|
assert.equal(r.naechsteIn(), 0) // abgelaufen, aber noch nicht abgeholt
|
||||||
|
assert.deepEqual(r.tick(), [])
|
||||||
|
assert.equal(r.naechsteIn(), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- Unbekannte Worker, kaputte Eingaben ----------
|
||||||
|
|
||||||
|
test('unbekannte Worker und Plätze erzeugen nichts', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt })
|
||||||
|
r.push([
|
||||||
|
ev('fund', 'niemand'), ev('gelesen', ''), ev('fund', '__proto__'), ev('fund', 'constructor'), ev('fund', 'toString'),
|
||||||
|
ev('uebergabe', 'lea', { to: 'niemand' }), ev('uebergabe', 'niemand', { to: 'lea' }), ev('pult', 'niemand'), ev('eingang', 'niemand'),
|
||||||
|
ev('auftrag', 'otto', { to: 'niemand' }),
|
||||||
|
])
|
||||||
|
// auftrag an einen Unbekannten bleibt ein reiner task-Schritt: auch Ottos Blase „… los!“ entfällt
|
||||||
|
assert.deepEqual(r.tick().map(kurz), [])
|
||||||
|
assert.equal(r.wartende(), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kaputte Eingaben: push mit Nicht-Listen, Nicht-Ereignissen, falschen Feldern und werfenden Getter wirft nie; gute Ereignisse laufen trotzdem', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt, max: 50, rueckstau: 50 })
|
||||||
|
for (const kaputt of [undefined, null, 5, 'text', {}, new Set([1]), () => {}]) assert.doesNotThrow(() => r.push(kaputt))
|
||||||
|
const werfend = { id: 'x', ts: '2026-10-17T20:00:00.000Z', workerId: 'lea', text: 'A', get kind() { throw new Error('kaputt') } }
|
||||||
|
assert.doesNotThrow(() => r.push([
|
||||||
|
null, undefined, 5, 'fund', [], {}, { kind: 'fund' }, { kind: 'fund', workerId: 5 }, { kind: 7, workerId: 'lea' },
|
||||||
|
{ kind: 'unbekannt', workerId: 'lea', text: 'T' }, { kind: 'gelesen', workerId: 'lea', meta: '{kaputt' }, { kind: 'gelesen', workerId: 'lea', meta: [] },
|
||||||
|
{ kind: 'deal', workerId: 'otto', meta: 5, text: {} }, werfend, ev('top3', 'bea'),
|
||||||
|
]))
|
||||||
|
const effekte = r.tick()
|
||||||
|
effekte.forEach(pruefeForm)
|
||||||
|
assert.ok(effekte.some((e) => e.art === 'blase' && e.wer === 'bea' && e.text === 'Top 3!'))
|
||||||
|
assert.ok(effekte.some((e) => e.art === 'blase' && e.wer === 'lea' && e.text === 'Gelesen'), 'kaputtes meta zählt wie leer')
|
||||||
|
assert.equal(effekte.filter((e) => e.art === 'blase' && e.wer === 'lea').length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Blasen mit leerem oder fremdem Text entfallen; Text wird nur gekürzt, nie als HTML gedeutet', () => {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt, max: 50 })
|
||||||
|
// eventToSteps setzt feste Texte; Ereignis-Texte landen nur in task und erscheinen nie in einer Blase
|
||||||
|
r.push([ev('fund', 'lea', { text: '<img src=x onerror=alert(1)>' })])
|
||||||
|
const texte = r.tick().filter((e) => e.art === 'blase').map((e) => e.text)
|
||||||
|
assert.deepEqual(texte, ['Neuer Fund!'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Optionen: ungültige max/rueckstau/jetzt fallen auf die Standardwerte zurück; kaputte Uhr stürzt nicht', () => {
|
||||||
|
for (const max of [NaN, 0, -3, 'a', null, Infinity]) {
|
||||||
|
const uhr = uhrVon()
|
||||||
|
const r = createRegie({ jetzt: uhr.jetzt, max, rueckstau: 'x' })
|
||||||
|
r.push(IDS.slice(0, 6).map((id) => ev('fund', id)))
|
||||||
|
assert.equal(r.tick().length, 4, `max ${String(max)}`)
|
||||||
|
assert.equal(r.wartende(), 8)
|
||||||
|
}
|
||||||
|
const r2 = createRegie({ jetzt: () => NaN })
|
||||||
|
assert.doesNotThrow(() => { r2.push([ev('fund', 'lea')]); r2.tick(); r2.naechsteIn() })
|
||||||
|
const r3 = createRegie({})
|
||||||
|
assert.doesNotThrow(() => r3.push([ev('fund', 'lea')]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Quelltext: rein (kein React, window, document, Zeitgeber); Importe nur workersEvents, hustlerWelt, workersRoster', () => {
|
||||||
|
const src = readFileSync(path.join(ROOT, 'src/lib/hustlerRegie.js'), 'utf8')
|
||||||
|
const code = src.replace(/\/\/.*$/gm, '')
|
||||||
|
assert.doesNotMatch(code, /\b(window|document|setTimeout|setInterval|localStorage)\b/)
|
||||||
|
assert.doesNotMatch(code, /from 'react'|from "react"/)
|
||||||
|
const importe = [...src.matchAll(/^import .* from '([^']+)'/gm)].map((m) => m[1])
|
||||||
|
for (const i of importe) assert.ok(['./workersEvents.js', './hustlerWelt.js', './workersRoster.js'].includes(i), i)
|
||||||
|
assert.doesNotMatch(src, /engine\/director/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- RegieEbene (Mini-Läufer) ----------
|
||||||
|
|
||||||
|
// fakeReact kennt forwardRef und useImperativeHandle nicht: hier angehängt (ref kommt wie bei React 18 als zweites Argument)
|
||||||
|
const FAKE_REACT_MIT_REF = `${FAKE_REACT}
|
||||||
|
export function forwardRef(render) { return (props) => render(props, props.ref ?? null) }
|
||||||
|
export function useImperativeHandle(ref, create, deps) {
|
||||||
|
useEffect(() => { if (ref) ref.current = create(); return () => { if (ref) ref.current = null } }, deps)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const fakeReactMitRef = {
|
||||||
|
name: 'fake-react-mit-ref',
|
||||||
|
setup(build) {
|
||||||
|
build.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: 'react', namespace: 'fake-react' }))
|
||||||
|
build.onLoad({ filter: /.*/, namespace: 'fake-react' }, () => ({ contents: FAKE_REACT_MIT_REF, loader: 'js' }))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
let ebeneBundle
|
||||||
|
const ladeEbene = () => (ebeneBundle ??= esbuild.build({
|
||||||
|
stdin: { contents: "export { __mount, jsx } from 'react'\nexport { default as RegieEbene } from './src/components/workers/hochhaus/RegieEbene.jsx'", resolveDir: ROOT, loader: 'js' },
|
||||||
|
bundle: true, format: 'esm', platform: 'neutral', write: false, jsx: 'automatic', loader: { '.jsx': 'jsx' }, plugins: [fakeReactMitRef],
|
||||||
|
}).then((out) => import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString('base64')}`)))
|
||||||
|
|
||||||
|
// Umgebung mit eigenem Zeitgeber, eigener Uhr (Date.now), matchMedia und document; ende() stellt alles zurück
|
||||||
|
function umgebung({ bewegung = false, verdeckt = false, ohneFenster = false } = {}) {
|
||||||
|
const alt = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout, setInterval: globalThis.setInterval, now: Date.now, window: globalThis.window, document: globalThis.document }
|
||||||
|
let t = 1_000_000
|
||||||
|
let nr = 0
|
||||||
|
const zeitgeber = new Map()
|
||||||
|
const intervalle = []
|
||||||
|
globalThis.setTimeout = (fn, ms) => { nr += 1; zeitgeber.set(nr, { fn, bei: t + (ms ?? 0) }); return nr }
|
||||||
|
globalThis.clearTimeout = (id) => { zeitgeber.delete(id) }
|
||||||
|
globalThis.setInterval = (...args) => { intervalle.push(args); return 0 }
|
||||||
|
Date.now = () => t
|
||||||
|
const media = {
|
||||||
|
matches: bewegung,
|
||||||
|
zuhoerer: new Set(),
|
||||||
|
addEventListener(art, fn) { if (art === 'change') this.zuhoerer.add(fn) },
|
||||||
|
removeEventListener(art, fn) { if (art === 'change') this.zuhoerer.delete(fn) },
|
||||||
|
aendern(matches) { this.matches = matches; for (const fn of [...this.zuhoerer]) fn({ matches }) },
|
||||||
|
}
|
||||||
|
const doc = {
|
||||||
|
hidden: verdeckt,
|
||||||
|
zuhoerer: new Set(),
|
||||||
|
addEventListener(art, fn) { if (art === 'visibilitychange') this.zuhoerer.add(fn) },
|
||||||
|
removeEventListener(art, fn) { if (art === 'visibilitychange') this.zuhoerer.delete(fn) },
|
||||||
|
sichtwechsel(hidden) { this.hidden = hidden; for (const fn of [...this.zuhoerer]) fn({}) },
|
||||||
|
}
|
||||||
|
const anfragen = []
|
||||||
|
if (ohneFenster) { delete globalThis.window; delete globalThis.document } else {
|
||||||
|
globalThis.window = { matchMedia: (q) => { anfragen.push(q); return media } }
|
||||||
|
globalThis.document = doc
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
media, doc, anfragen, intervalle,
|
||||||
|
jetzt: () => t,
|
||||||
|
offene: () => zeitgeber.size,
|
||||||
|
// Zeit vorstellen und dabei die fälligen Zeitgeber in ihrer Reihenfolge auslösen (t steht dann auf dem Zeitpunkt des Zeitgebers)
|
||||||
|
vor(ms) {
|
||||||
|
const ziel = t + ms
|
||||||
|
for (;;) {
|
||||||
|
const faellig = [...zeitgeber].filter(([, z]) => z.bei <= ziel).sort((a, b) => a[1].bei - b[1].bei)[0]
|
||||||
|
if (!faellig) break
|
||||||
|
zeitgeber.delete(faellig[0])
|
||||||
|
t = Math.max(t, faellig[1].bei)
|
||||||
|
faellig[1].fn()
|
||||||
|
}
|
||||||
|
t = ziel
|
||||||
|
},
|
||||||
|
ende() {
|
||||||
|
globalThis.setTimeout = alt.setTimeout; globalThis.clearTimeout = alt.clearTimeout; globalThis.setInterval = alt.setInterval; Date.now = alt.now
|
||||||
|
if (alt.window === undefined) delete globalThis.window; else globalThis.window = alt.window
|
||||||
|
if (alt.document === undefined) delete globalThis.document; else globalThis.document = alt.document
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = (node, passt, out = []) => {
|
||||||
|
if (node === null || typeof node === 'string') return out
|
||||||
|
if (passt(node)) out.push(node)
|
||||||
|
for (const kind of node.children) nodes(kind, passt, out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
const textVon = (node) => (typeof node === 'string' ? node : node === null ? '' : node.children.map(textVon).join(''))
|
||||||
|
const hatKlasse = (n, name) => String(n.props.className ?? '').split(' ').includes(name)
|
||||||
|
const nachKlasse = (baum, name) => nodes(baum, (n) => typeof n.tag === 'string' && hatKlasse(n, name))
|
||||||
|
|
||||||
|
// Ebene einhängen; liefert Baum, Handle und Abbau
|
||||||
|
async function ebene(umg) {
|
||||||
|
const { __mount, jsx, RegieEbene } = await ladeEbene()
|
||||||
|
const ref = { current: null }
|
||||||
|
const gemountet = __mount(jsx(RegieEbene, { ref }))
|
||||||
|
return { ref, tree: () => gemountet.tree(), abbau: () => gemountet.unmount(), spaet: () => gemountet.lateUpdates() }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('RegieEbene: leere Ebene mit hk-regie, aria-hidden, Handle { push, flush }, kein Zeitgeber und kein Intervall im Ruhezustand', async () => {
|
||||||
|
const umg = umgebung()
|
||||||
|
try {
|
||||||
|
const v = await ebene(umg)
|
||||||
|
const wurzel = v.tree()
|
||||||
|
assert.equal(wurzel.tag, 'div'); assert.equal(wurzel.props.className, 'hk-regie'); assert.equal(wurzel.props['aria-hidden'], 'true')
|
||||||
|
assert.deepEqual(wurzel.children, [])
|
||||||
|
assert.deepEqual(Object.keys(v.ref.current).sort(), ['flush', 'push'])
|
||||||
|
assert.equal(umg.offene(), 0)
|
||||||
|
assert.equal(umg.intervalle.length, 0)
|
||||||
|
assert.deepEqual(umg.anfragen, ['(prefers-reduced-motion: reduce)'])
|
||||||
|
assert.equal(umg.media.zuhoerer.size, 1); assert.equal(umg.doc.zuhoerer.size, 1)
|
||||||
|
v.abbau()
|
||||||
|
} finally { umg.ende() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('RegieEbene: Blase in Weltkoordinaten (left/top am Kopf, --dauer), Text als Textknoten; nach Ablauf weg und kein Zeitgeber mehr', async () => {
|
||||||
|
const umg = umgebung()
|
||||||
|
try {
|
||||||
|
const v = await ebene(umg)
|
||||||
|
v.ref.current.push([ev('gelesen', 'lea', { meta: { count: 3 } })])
|
||||||
|
const blasen = nachKlasse(v.tree(), 'hk-regie-blase')
|
||||||
|
assert.equal(blasen.length, 1)
|
||||||
|
assert.deepEqual({ ...blasen[0].props.style }, { left: `${HUSTLER.lea.a}px`, top: `${HUSTLER.lea.f - 36}px`, '--dauer': '2500ms' })
|
||||||
|
const text = nachKlasse(blasen[0], 'hk-regie-blase-text')
|
||||||
|
assert.equal(text.length, 1)
|
||||||
|
assert.deepEqual(text[0].children, ['3 gelesen'])
|
||||||
|
assert.equal(umg.offene(), 1, 'genau ein Zeitgeber bis zum Ablauf')
|
||||||
|
umg.vor(2499)
|
||||||
|
assert.equal(nachKlasse(v.tree(), 'hk-regie-blase').length, 1)
|
||||||
|
umg.vor(1)
|
||||||
|
assert.deepEqual(v.tree().children, [])
|
||||||
|
assert.equal(umg.offene(), 0)
|
||||||
|
assert.equal(umg.intervalle.length, 0)
|
||||||
|
v.abbau()
|
||||||
|
} finally { umg.ende() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('RegieEbene: Symbol (Klasse hk-regie-icon + hk-regie-bild-akte) neben der Figur, Blase bleibt länger als das Symbol', async () => {
|
||||||
|
const umg = umgebung()
|
||||||
|
try {
|
||||||
|
const v = await ebene(umg)
|
||||||
|
v.ref.current.push([ev('fund', 'lea')])
|
||||||
|
const icons = nachKlasse(v.tree(), 'hk-regie-icon')
|
||||||
|
assert.equal(icons.length, 1)
|
||||||
|
assert.ok(hatKlasse(icons[0], 'hk-regie-bild-akte'))
|
||||||
|
assert.equal(icons[0].props.style.left, `${HUSTLER.lea.a + 22}px`)
|
||||||
|
assert.equal(icons[0].props.style.top, `${HUSTLER.lea.f - 26}px`)
|
||||||
|
assert.equal(icons[0].props.style['--dauer'], '1200ms')
|
||||||
|
assert.equal(nachKlasse(v.tree(), 'hk-regie-blase').length, 1)
|
||||||
|
umg.vor(1200)
|
||||||
|
assert.equal(nachKlasse(v.tree(), 'hk-regie-icon').length, 0)
|
||||||
|
assert.equal(nachKlasse(v.tree(), 'hk-regie-blase').length, 1)
|
||||||
|
umg.vor(1300)
|
||||||
|
assert.deepEqual(v.tree().children, [])
|
||||||
|
assert.equal(umg.offene(), 0)
|
||||||
|
v.abbau()
|
||||||
|
} finally { umg.ende() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('RegieEbene: Flug mit Start (left/top) und Weg zum Ziel (--dx/--dy); gesendet fliegt von Max nach „aussen“', async () => {
|
||||||
|
const umg = umgebung()
|
||||||
|
try {
|
||||||
|
const v = await ebene(umg)
|
||||||
|
v.ref.current.push([ev('gesendet', 'max')])
|
||||||
|
const fluege = nachKlasse(v.tree(), 'hk-regie-flug')
|
||||||
|
assert.equal(fluege.length, 1)
|
||||||
|
assert.ok(hatKlasse(fluege[0], 'hk-regie-bild-umschlag'))
|
||||||
|
const sx = HUSTLER.max.a
|
||||||
|
const sy = HUSTLER.max.f - 16
|
||||||
|
assert.deepEqual({ ...fluege[0].props.style }, { left: `${sx}px`, top: `${sy}px`, '--dauer': '900ms', '--dx': `${PLAETZE.aussen.x - sx}px`, '--dy': `${PLAETZE.aussen.y - sy}px` })
|
||||||
|
umg.vor(900)
|
||||||
|
assert.equal(nachKlasse(v.tree(), 'hk-regie-flug').length, 0)
|
||||||
|
v.abbau()
|
||||||
|
} finally { umg.ende() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('RegieEbene: deal zeigt Münze 1, Jubel und Blase, 400 ms später Münze 2, und Münze 3 sobald ein Platz frei ist (Höchstzahl 4)', async () => {
|
||||||
|
const umg = umgebung()
|
||||||
|
try {
|
||||||
|
const v = await ebene(umg)
|
||||||
|
v.ref.current.push([ev('deal', 'otto')])
|
||||||
|
const muenzen = () => nachKlasse(v.tree(), 'hk-regie-bild-muenze')
|
||||||
|
assert.equal(muenzen().length, 1)
|
||||||
|
assert.equal(nachKlasse(v.tree(), 'hk-regie-jubel').length, 1)
|
||||||
|
assert.ok(hatKlasse(nachKlasse(v.tree(), 'hk-regie-jubel')[0], 'hk-regie-bild-stern'))
|
||||||
|
assert.equal(nachKlasse(v.tree(), 'hk-regie-blase').length, 1)
|
||||||
|
umg.vor(400)
|
||||||
|
assert.equal(muenzen().length, 2)
|
||||||
|
umg.vor(400)
|
||||||
|
assert.equal(muenzen().length, 2, 'bei 800 ms sind vier Plätze belegt')
|
||||||
|
umg.vor(100)
|
||||||
|
assert.equal(muenzen().length, 2, 'erste Münze weg, dritte da')
|
||||||
|
umg.vor(400)
|
||||||
|
assert.equal(muenzen().length, 1)
|
||||||
|
umg.vor(2500)
|
||||||
|
assert.deepEqual(v.tree().children, [])
|
||||||
|
assert.equal(umg.offene(), 0)
|
||||||
|
v.abbau()
|
||||||
|
} finally { umg.ende() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('RegieEbene: Elemente behalten ihre key-Kennung, solange sie laufen (kein Neustart der CSS-Animation durch andere Effekte)', async () => {
|
||||||
|
const umg = umgebung()
|
||||||
|
try {
|
||||||
|
const v = await ebene(umg)
|
||||||
|
v.ref.current.push([ev('gelesen', 'lea')])
|
||||||
|
const erste = nachKlasse(v.tree(), 'hk-regie-blase')[0]
|
||||||
|
umg.vor(300)
|
||||||
|
v.ref.current.push([ev('gelesen', 'sven')])
|
||||||
|
const blasen = nachKlasse(v.tree(), 'hk-regie-blase')
|
||||||
|
assert.equal(blasen.length, 2)
|
||||||
|
assert.equal(blasen[0].props.style.left, erste.props.style.left)
|
||||||
|
v.abbau()
|
||||||
|
} finally { umg.ende() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('RegieEbene: „weniger Bewegung“ -> nur Blasen; Wechsel über den Änderungs-Listener wirkt sofort (laufende Flüge verschwinden)', async () => {
|
||||||
|
const umg = umgebung({ bewegung: true })
|
||||||
|
try {
|
||||||
|
const v = await ebene(umg)
|
||||||
|
v.ref.current.push([ev('deal', 'otto'), ev('fund', 'lea')])
|
||||||
|
assert.equal(nachKlasse(v.tree(), 'hk-regie-blase').length, 2)
|
||||||
|
for (const k of ['hk-regie-flug', 'hk-regie-icon', 'hk-regie-jubel']) assert.equal(nachKlasse(v.tree(), k).length, 0, k)
|
||||||
|
umg.media.aendern(false)
|
||||||
|
v.ref.current.push([ev('deal', 'otto')])
|
||||||
|
assert.equal(nachKlasse(v.tree(), 'hk-regie-flug').length, 1)
|
||||||
|
assert.equal(nachKlasse(v.tree(), 'hk-regie-jubel').length, 1)
|
||||||
|
umg.media.aendern(true)
|
||||||
|
assert.equal(nachKlasse(v.tree(), 'hk-regie-flug').length, 0)
|
||||||
|
assert.equal(nachKlasse(v.tree(), 'hk-regie-jubel').length, 0)
|
||||||
|
assert.ok(nachKlasse(v.tree(), 'hk-regie-blase').length >= 1)
|
||||||
|
umg.vor(5000)
|
||||||
|
assert.deepEqual(v.tree().children, [])
|
||||||
|
assert.equal(umg.offene(), 0)
|
||||||
|
v.abbau()
|
||||||
|
} finally { umg.ende() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('RegieEbene: verdeckter Tab leert die Ebene und stoppt den Zeitgeber; solange er verdeckt ist, kommt nichts an; danach geht es weiter', async () => {
|
||||||
|
const umg = umgebung()
|
||||||
|
try {
|
||||||
|
const v = await ebene(umg)
|
||||||
|
v.ref.current.push([ev('deal', 'otto'), ev('fund', 'lea')])
|
||||||
|
assert.ok(v.tree().children.length > 0)
|
||||||
|
assert.ok(umg.offene() > 0)
|
||||||
|
umg.doc.sichtwechsel(true)
|
||||||
|
assert.deepEqual(v.tree().children, [])
|
||||||
|
assert.equal(umg.offene(), 0)
|
||||||
|
v.ref.current.push([ev('fund', 'bea')])
|
||||||
|
assert.deepEqual(v.tree().children, [], 'verdeckt: nichts sammelt sich an')
|
||||||
|
assert.equal(umg.offene(), 0)
|
||||||
|
umg.doc.sichtwechsel(false)
|
||||||
|
assert.deepEqual(v.tree().children, [])
|
||||||
|
v.ref.current.push([ev('fund', 'bea')])
|
||||||
|
assert.equal(nachKlasse(v.tree(), 'hk-regie-blase').length, 1)
|
||||||
|
v.abbau()
|
||||||
|
} finally { umg.ende() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('RegieEbene: flush über das Handle leert alles und räumt den Zeitgeber auf', async () => {
|
||||||
|
const umg = umgebung()
|
||||||
|
try {
|
||||||
|
const v = await ebene(umg)
|
||||||
|
v.ref.current.push([ev('deal', 'otto'), ev('fund', 'lea')])
|
||||||
|
assert.ok(umg.offene() > 0)
|
||||||
|
v.ref.current.flush()
|
||||||
|
assert.deepEqual(v.tree().children, [])
|
||||||
|
assert.equal(umg.offene(), 0)
|
||||||
|
v.ref.current.push([ev('fund', 'lea')])
|
||||||
|
assert.equal(nachKlasse(v.tree(), 'hk-regie-blase').length, 1)
|
||||||
|
v.abbau()
|
||||||
|
} finally { umg.ende() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('RegieEbene: Abbau räumt Zeitgeber und Beobachter auf, nimmt das Handle zurück; späte Aufrufe und Zeitgeber ändern nichts', async () => {
|
||||||
|
const umg = umgebung()
|
||||||
|
try {
|
||||||
|
const v = await ebene(umg)
|
||||||
|
const handle = v.ref.current
|
||||||
|
handle.push([ev('deal', 'otto'), ev('fund', 'lea')])
|
||||||
|
assert.ok(umg.offene() > 0)
|
||||||
|
v.abbau()
|
||||||
|
assert.equal(umg.offene(), 0)
|
||||||
|
assert.equal(umg.media.zuhoerer.size, 0)
|
||||||
|
assert.equal(umg.doc.zuhoerer.size, 0)
|
||||||
|
assert.equal(v.ref.current, null)
|
||||||
|
handle.push([ev('fund', 'lea')])
|
||||||
|
handle.flush()
|
||||||
|
assert.equal(umg.offene(), 0, 'nach dem Abbau startet kein neuer Zeitgeber')
|
||||||
|
assert.equal(v.spaet(), 0, 'keine Zustandsänderung nach dem Abbau')
|
||||||
|
assert.equal(umg.intervalle.length, 0)
|
||||||
|
} finally { umg.ende() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('RegieEbene: ohne window und document (z. B. Vorschau) läuft sie trotzdem', async () => {
|
||||||
|
const umg = umgebung({ ohneFenster: true })
|
||||||
|
try {
|
||||||
|
const v = await ebene(umg)
|
||||||
|
v.ref.current.push([ev('fund', 'lea')])
|
||||||
|
assert.equal(nachKlasse(v.tree(), 'hk-regie-icon').length, 1)
|
||||||
|
umg.vor(5000)
|
||||||
|
assert.deepEqual(v.tree().children, [])
|
||||||
|
v.abbau()
|
||||||
|
assert.equal(umg.offene(), 0)
|
||||||
|
} finally { umg.ende() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('RegieEbene: kaputte Eingaben über das Handle werfen nicht', async () => {
|
||||||
|
const umg = umgebung()
|
||||||
|
try {
|
||||||
|
const v = await ebene(umg)
|
||||||
|
for (const kaputt of [undefined, null, 'x', {}, [null, 5, { kind: 'fund' }]]) assert.doesNotThrow(() => v.ref.current.push(kaputt))
|
||||||
|
assert.deepEqual(v.tree().children, [])
|
||||||
|
assert.equal(umg.offene(), 0)
|
||||||
|
v.abbau()
|
||||||
|
} finally { umg.ende() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('RegieEbene: Quelltext ohne dangerouslySetInnerHTML/innerHTML und ohne Dauer-Intervall, Klassen alle mit hk-regie', () => {
|
||||||
|
const src = readFileSync(path.join(ROOT, 'src/components/workers/hochhaus/RegieEbene.jsx'), 'utf8')
|
||||||
|
assert.doesNotMatch(src, /dangerouslySetInnerHTML|innerHTML|setInterval/)
|
||||||
|
assert.doesNotMatch(src, /^import .*\.(css|png)'/m)
|
||||||
|
for (const m of src.matchAll(/className=(?:"([^"]+)"|\{`([^`]+)`\})/g)) {
|
||||||
|
for (const klasse of (m[1] ?? m[2]).split(/\s+/)) assert.match(klasse, /^hk-regie/, klasse)
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(src, /engine\/director/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------- hustler-regie.css ----------
|
||||||
|
|
||||||
|
const cssText = readFileSync(path.join(ROOT, 'src/styles/hustler/hustler-regie.css'), 'utf8')
|
||||||
|
const cssOhneKommentare = cssText.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||||
|
|
||||||
|
// -> { regeln: [{ selektor, koerper, kontext }], keyframes: [name] }
|
||||||
|
function zerlege(text) {
|
||||||
|
const regeln = []
|
||||||
|
const keyframes = []
|
||||||
|
let i = 0
|
||||||
|
function liste(kontext) {
|
||||||
|
for (;;) {
|
||||||
|
while (i < text.length && /\s/.test(text[i])) i += 1
|
||||||
|
if (i >= text.length) return
|
||||||
|
if (text[i] === '}') { i += 1; return }
|
||||||
|
const start = i
|
||||||
|
while (i < text.length && text[i] !== '{' && text[i] !== '}') i += 1
|
||||||
|
const kopf = text.slice(start, i).trim()
|
||||||
|
assert.equal(text[i], '{', `Regel ohne Block: ${kopf.slice(0, 60)}`)
|
||||||
|
i += 1
|
||||||
|
if (kopf.startsWith('@keyframes')) {
|
||||||
|
keyframes.push(kopf.slice('@keyframes'.length).trim())
|
||||||
|
let tiefe = 1
|
||||||
|
while (i < text.length && tiefe > 0) { if (text[i] === '{') tiefe += 1; if (text[i] === '}') tiefe -= 1; i += 1 }
|
||||||
|
} else if (kopf.startsWith('@media')) {
|
||||||
|
liste(kopf)
|
||||||
|
} else {
|
||||||
|
const ks = i
|
||||||
|
while (i < text.length && text[i] !== '}') i += 1
|
||||||
|
regeln.push({ selektor: kopf, koerper: text.slice(ks, i), kontext })
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
liste('')
|
||||||
|
return { regeln, keyframes }
|
||||||
|
}
|
||||||
|
const css = zerlege(cssOhneKommentare)
|
||||||
|
|
||||||
|
test('CSS: Kopfkommentar nennt Aufgabe und Namensräume; mehr als zehn Regeln gelesen', () => {
|
||||||
|
assert.match(cssText.split('\n')[0], /^\/\* Regie der Hochhaus-Welt/)
|
||||||
|
assert.ok(css.regeln.length > 10, `nur ${css.regeln.length} Regeln`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CSS: jede Bilddatei aus url(...) existiert unter src/assets/hustler (relativer Pfad, PNG), und die gewünschten Icons sind alle da', () => {
|
||||||
|
const urls = [...cssOhneKommentare.matchAll(/url\(\s*([^)]*?)\s*\)/g)].map((m) => m[1].replace(/^["']|["']$/g, ''))
|
||||||
|
assert.equal(urls.length, 7)
|
||||||
|
const assets = path.join(ROOT, 'src/assets/hustler')
|
||||||
|
for (const u of urls) {
|
||||||
|
assert.match(u, /^\.\.\/\.\.\/assets\/hustler\/[\w./-]+\.png$/, u)
|
||||||
|
const ziel = path.resolve(path.join(ROOT, 'src/styles/hustler'), u)
|
||||||
|
assert.ok(ziel.startsWith(assets + path.sep), `${u} liegt außerhalb`)
|
||||||
|
assert.ok(existsSync(ziel) && statSync(ziel).isFile(), `${u} fehlt`)
|
||||||
|
}
|
||||||
|
assert.deepEqual([...urls].map((u) => u.replace('../../assets/hustler/', '')).sort(), [
|
||||||
|
'keller/ui/akte-icon.png', 'keller/ui/haken.png', 'keller/ui/stopp.png', 'pult/ui/art-beschwerde.png', 'pult/ui/art-mail.png', 'ui/muenze-gruen.png', 'ui/stern.png',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CSS: jede Klasse in einem Selektor heißt hk-regie…, jede Regel steht unter .hk-hochhaus (auch im Media-Block)', () => {
|
||||||
|
for (const r of css.regeln) {
|
||||||
|
for (const teil of r.selektor.split(',').map((s) => s.trim())) {
|
||||||
|
assert.ok(teil.startsWith('.hk-hochhaus '), `nicht unter .hk-hochhaus: ${teil}`)
|
||||||
|
const klassen = [...teil.matchAll(/\.(-?[A-Za-z_][\w-]*)/g)].map((m) => m[1]).slice(1)
|
||||||
|
assert.ok(klassen.length >= 1, `keine Klasse: ${teil}`)
|
||||||
|
for (const k of klassen) assert.match(k, /^hk-regie(-|$)/, `${teil}: ${k}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const name of css.keyframes) assert.match(name, /^hk-regie-/, name)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CSS: alle von der Ebene gesetzten Klassen sind definiert, auch jedes Bild je Symbolname (Regie-Namen)', () => {
|
||||||
|
const definiert = new Set(css.regeln.flatMap((r) => [...r.selektor.matchAll(/\.(hk-regie[\w-]*)/g)].map((m) => m[1])))
|
||||||
|
const src = readFileSync(path.join(ROOT, 'src/components/workers/hochhaus/RegieEbene.jsx'), 'utf8')
|
||||||
|
for (const m of src.matchAll(/hk-regie[\w-]*/g)) {
|
||||||
|
if (m[0] === 'hk-regie-bild-') continue // dynamisch: je Symbolname
|
||||||
|
assert.ok(definiert.has(m[0]), `${m[0]} fehlt in hustler-regie.css`)
|
||||||
|
}
|
||||||
|
const symbole = new Set([...Object.values(ICON_NACH_FX), 'umschlag', 'muenze', 'stern'])
|
||||||
|
assert.deepEqual([...symbole].sort(), ['akte', 'alarm', 'haken', 'muenze', 'stern', 'stopp', 'umschlag'])
|
||||||
|
for (const s of symbole) assert.ok(definiert.has(`hk-regie-bild-${s}`), `hk-regie-bild-${s}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CSS: Keyframes sind definiert und werden benutzt; Dauer kommt aus --dauer', () => {
|
||||||
|
const benutzt = new Set([...cssOhneKommentare.matchAll(/animation:\s*(hk-regie-[\w-]+)/g)].map((m) => m[1]))
|
||||||
|
assert.deepEqual([...benutzt].sort(), [...css.keyframes].sort())
|
||||||
|
for (const name of benutzt) assert.ok(css.keyframes.includes(name), name)
|
||||||
|
for (const m of cssOhneKommentare.matchAll(/animation:\s*hk-regie-[\w-]+\s+var\(--dauer, (\d+)ms\)/g)) assert.ok(Number(m[1]) > 0)
|
||||||
|
// Standarddauer in der CSS = Dauer der Regie
|
||||||
|
assert.match(cssOhneKommentare, new RegExp(`hk-regie-blase var\\(--dauer, ${DAUER.blase}ms\\)`))
|
||||||
|
assert.match(cssOhneKommentare, new RegExp(`hk-regie-pop var\\(--dauer, ${DAUER.icon}ms\\)`))
|
||||||
|
assert.match(cssOhneKommentare, new RegExp(`hk-regie-flug var\\(--dauer, ${DAUER.flug}ms\\)`))
|
||||||
|
assert.match(cssOhneKommentare, new RegExp(`hk-regie-jubel var\\(--dauer, ${DAUER.jubel}ms\\)`))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CSS: Ebene nimmt keine Klicks an, Pixel-Darstellung, Symbole 16 px (Sprite-Blätter: nur das erste Bild), „weniger Bewegung“ ohne Animation', () => {
|
||||||
|
const regel = (teil) => css.regeln.filter((r) => r.selektor.split(',').map((s) => s.trim()).includes(teil))
|
||||||
|
const ebeneRegeln = regel('.hk-hochhaus .hk-regie')
|
||||||
|
assert.equal(ebeneRegeln.length, 1)
|
||||||
|
assert.match(ebeneRegeln[0].koerper, /pointer-events:\s*none/)
|
||||||
|
assert.match(ebeneRegeln[0].koerper, /image-rendering:\s*pixelated/)
|
||||||
|
assert.ok(regel('.hk-hochhaus .hk-regie *').some((r) => /pointer-events:\s*none/.test(r.koerper)))
|
||||||
|
const bild = regel('.hk-hochhaus .hk-regie-icon')[0]
|
||||||
|
assert.match(bild.koerper, /width:\s*16px/); assert.match(bild.koerper, /height:\s*16px/)
|
||||||
|
assert.match(bild.koerper, /image-rendering:\s*pixelated/)
|
||||||
|
assert.match(bild.koerper, /background-position:\s*0 0/) // erstes Bild
|
||||||
|
assert.match(bild.koerper, /background-size:\s*auto 16px/) // Sprite-Blatt in Originalgröße, nur Zelle 1 sichtbar
|
||||||
|
// Blase: Pixelify Sans, dunkler Rand, Dreieck
|
||||||
|
const text = regel('.hk-hochhaus .hk-regie-blase-text')[0].koerper
|
||||||
|
assert.match(text, /Pixelify Sans/); assert.match(text, /box-shadow/)
|
||||||
|
assert.ok(regel('.hk-hochhaus .hk-regie-blase-text::after').length === 1)
|
||||||
|
// weniger Bewegung
|
||||||
|
const media = css.regeln.filter((r) => r.kontext.includes('prefers-reduced-motion: reduce'))
|
||||||
|
assert.ok(media.length >= 1)
|
||||||
|
const ohne = media.flatMap((r) => r.selektor.split(',').map((s) => s.trim()))
|
||||||
|
for (const k of ['blase-text', 'icon', 'flug', 'jubel']) assert.ok(ohne.includes(`.hk-hochhaus .hk-regie-${k}`), k)
|
||||||
|
for (const r of media) assert.match(r.koerper, /animation:\s*none/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('CSS: Symbole auf die Größen der Welt begrenzt (12–16 px), keine @import/@font-face/googleapis', () => {
|
||||||
|
for (const r of css.regeln) {
|
||||||
|
for (const m of r.koerper.matchAll(/(?<![\w-])(?:width|height):\s*(\d+)px/g)) assert.ok(Number(m[1]) <= 16, `${r.selektor}: ${m[0]}`)
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(cssOhneKommentare, /@import|@font-face|googleapis|\/_blob\//)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user