Hustler-Pixelwelt T11: alte helle Oberfläche und Kanvas-Engine entfernt, workers.css gekürzt, Zusicherungen in die neuen Tests übertragen

- gelöscht: components/workers/engine/* (10 Dateien), WorkersStage, WorkersBoard, WorkerCard, WorkerList, WorkersKpis,
  die neun Engine-Tests samt workersSmoke (211 Tests) und die Helfer engineVm.mjs und fakeCtx.mjs (nur die Engine-Tests brauchten sie)
- workers.css (310 -> 60 Zeilen) behält nur Einstellungen-Dialog und Interessenten-Panel; @font-face für Pixelify Sans steht jetzt in hustler/schrift.css
- workersComponents: kein innerHTML und kein Kanvas rekursiv unter components/workers/**, jeder Knopf mit type, Links nur aus geprüften Feldern
  (externe mit noopener), keine Anmeldung in den Bildschirmen, alte Dateien weg und nirgends mehr importiert
- workersCss: jede wk-Klasse im Code hat eine Regel, keine Regel ohne Nutzer; workersFont: Pixelify Sans genau einmal in schrift.css
- hustlerAssets: Pixelify Sans in schrift.css erlaubt; hustlerDialog: Link-Ziele nur aus safeLink, Knöpfe mit type; hustlerSeite: Laden/Fehler/bereit am selben Einhängepunkt

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-22 09:09:37 +00:00
1 parent 4be9750f36
commit ba007a325a
35 files changed
+277 -6495

No files matched your search

-144
View File
@@ -1,144 +0,0 @@
import { useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import Badge from '../ui/Badge'
import { counterLines, formatCount, formatUsd, safeLink, statusTone, tokenTotal, traceRows, workerStatusLabel } from '../../lib/workersView'
import { MODEL_IDS, hatForWorker, roleOf } from '../../lib/workersRoster'
import { drawWorker, setGrid } from './engine/sprites'
const DASH = '–'
// Modell-Chip je Hut (Wörterbuch statt zusammengesetzter Klasse: der CSS-Test in workers.css findet die Namen)
const CHIP = {
opus: 'wk-chip-opus',
sonnet: 'wk-chip-sonnet',
haiku: 'wk-chip-haiku',
overseer: 'wk-chip-overseer',
}
const PORTRAIT_W = 72
const PORTRAIT_H = 96
// Beginnt die Karte tiefer als so viele Pixel über der Unterkante des Fensters, wird sie nach oben geholt
const MIN_VISIBLE = 120
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
// Status aus dem Zustandseintrag; ein nicht angeschlossener Worker ist grau (wie in indexWorkers)
function statusOf(worker) {
if (!isRecord(worker)) return ''
return worker.connected === false ? 'grau' : worker.status
}
// Porträt der Figur auf einem eigenen Canvas (Raster 4, Figur bei 6/8)
function Portrait({ hat, grey }) {
const ref = useRef(null)
useEffect(() => {
const canvas = ref.current
const ctx = canvas && canvas.getContext ? canvas.getContext('2d') : null
if (!ctx) return
ctx.clearRect(0, 0, PORTRAIT_W, PORTRAIT_H)
setGrid(ctx, { k: 4 })
drawWorker(ctx, 6, 8, { hat, grey, scale: 1 })
}, [hat, grey])
return <canvas ref={ref} className="wk-portrait" width={PORTRAIT_W} height={PORTRAIT_H} aria-hidden="true" />
}
// Einziger Ort der Karte, an dem ein Ziel für einen Link entsteht: nur was safeLink durchlässt wird ein Link
// (extern in neuem Tab mit noopener, intern über den Router), alles andere bleibt Text.
function TraceText({ link, children }) {
const safe = link ? safeLink(link.href) : null
if (!safe) return <>{children}</>
if (safe.external) return <a href={safe.href} target="_blank" rel="noopener noreferrer">{children}</a>
return <Link to={safe.href}>{children}</Link>
}
// Karte eines Workers: Porträt, Rolle, Modell, Status, Tageswerte und die letzten Aktionen.
// worker = Zustandseintrag (status, task, counters, connected), trace = Antwort von api.trace (oder null).
export default function WorkerCard({ workerId, worker, trace, loading, error, onClose }) {
const sectionRef = useRef(null)
const headingRef = useRef(null)
// Neue Auswahl: Der Name nimmt den Fokus (Screenreader sagen die Karte an), und liegt der Kartenanfang außerhalb des Fensters,
// wird die Karte an den oberen Rand geholt. Auf Handy und Tablet steht die Karte über der langen Liste, aus der man wählt.
useEffect(() => {
if (!workerId) return
const heading = headingRef.current
if (heading && typeof heading.focus === 'function') heading.focus({ preventScroll: true })
const section = sectionRef.current
if (!section || typeof section.scrollIntoView !== 'function') return
const top = section.getBoundingClientRect().top
const viewport = window.innerHeight || document.documentElement.clientHeight
const margin = parseFloat(window.getComputedStyle(section).scrollMarginTop) || 0
if (top < margin || top > viewport - MIN_VISIBLE) section.scrollIntoView({ block: 'start' })
}, [workerId])
if (!workerId) {
return (
<div className="wk-card">
<p className="wk-hint">Klicke eine Figur oder wähle einen Worker aus der Liste.</p>
</div>
)
}
const role = roleOf(workerId)
const info = trace && isRecord(trace.worker) ? trace.worker : null
const name = (info && info.name) || (role && role.name) || workerId
const roleText = (info && info.role) || (role && role.role) || ''
const modelId = (info && info.modelId) || (role ? MODEL_IDS[role.model] : '')
const hat = hatForWorker(workerId)
const status = statusOf(worker)
const today = trace && isRecord(trace.today) ? trace.today : null
const counters = counterLines(today && isRecord(today.counters) ? today.counters : worker && worker.counters)
const task = isRecord(worker) && typeof worker.task === 'string' && worker.task.trim() !== '' ? worker.task : DASH
const rows = traceRows(trace && trace.events, Date.now())
return (
<section className="wk-card" aria-label="Worker-Karte" ref={sectionRef}>
{onClose && <button type="button" className="btn btn-quiet" onClick={onClose} aria-label="Karte schließen">×</button>}
<Portrait hat={hat} grey={status === 'grau'} />
<h2 className="wk-name" ref={headingRef} tabIndex={-1}>{name}</h2>
{roleText && <p className="wk-role">{roleText}</p>}
<p>
{modelId && <span className={`wk-chip ${CHIP[hat]}`}>{modelId}</span>}
<Badge tone={statusTone(status)}>{workerStatusLabel(status)}</Badge>
</p>
{error && <div className="msg err" role="alert">{error}</div>}
<dl className="wk-facts">
<div><dt>Macht gerade</dt><dd>{task}</dd></div>
<div><dt>Tokens heute</dt><dd>{formatCount(today ? tokenTotal(today.tokens) : null)}</dd></div>
<div><dt>Kosten heute</dt><dd>{formatUsd(today ? today.costUsd : null)}</dd></div>
</dl>
{counters.length > 0 && (
<div className="wk-counters">
<h3>Zähler heute</h3>
<dl>
{counters.map((c) => (
<div key={c.key}><dt>{c.label}</dt><dd>{c.value}</dd></div>
))}
</dl>
</div>
)}
{(loading || trace) && (
<>
<h3>Letzte Aktionen</h3>
{loading && <p className="wk-hint">Spur wird geladen …</p>}
{!loading && rows.length === 0 && <p className="wk-hint">Noch keine Aktionen.</p>}
{rows.length > 0 && (
<ol className="wk-trace">
{rows.map((row, i) => (
<li key={`${row.id}-${i}`}>
<span className="wk-trace-time">{row.time}</span>{' '}
<TraceText link={row.link}>{row.text}</TraceText>
</li>
))}
</ol>
)}
</>
)}
</section>
)
}
-62
View File
@@ -1,62 +0,0 @@
import { workerStatusLabel } from '../../lib/workersView'
import { FLOORS, FLOOR_TITLES, ROLES } from '../../lib/workersRoster'
// Statuspunkt je Dienst-Status (Wörterbuch statt zusammengesetzter Klasse: der CSS-Test in workers.css findet die Namen)
const DOT = {
arbeitet: 'wk-dot-arbeitet',
wartet: 'wk-dot-wartet',
leerlauf: 'wk-dot-leerlauf',
grau: 'wk-dot-grau',
fehler: 'wk-dot-fehler',
}
// Etagen von oben nach unten mit ihren Workern; Etagen ohne Worker (Pult, Tresor) entfallen
const GROUPS = FLOORS.map((floor) => ({ floor, roles: ROLES.filter((r) => r.floor === floor) })).filter((g) => g.roles.length > 0)
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
// workers = Tabelle nach workerId (indexWorkers/reduceEvents) oder die Liste aus state.workers; unbekannt -> null
function entryOf(workers, workerId) {
if (Array.isArray(workers)) return workers.find((w) => isRecord(w) && w.workerId === workerId) ?? null
return isRecord(workers) && Object.hasOwn(workers, workerId) && isRecord(workers[workerId]) ? workers[workerId] : null
}
// Status aus dem Eintrag; ein nicht angeschlossener Worker ist grau (wie in indexWorkers)
function statusOf(entry) {
if (!entry) return ''
return entry.connected === false ? 'grau' : entry.status
}
// Alle Worker je Etage als Knopfliste. Ein Klick meldet die workerId über onSelect, der gewählte Knopf ist aria-pressed.
export default function WorkerList({ workers, selectedId, onSelect }) {
return (
<div className="wk-list">
{GROUPS.map((group) => (
<section key={group.floor}>
<h3 className="wk-list-floor">{FLOOR_TITLES[group.floor]}</h3>
<ul>
{group.roles.map((role) => {
const status = statusOf(entryOf(workers, role.workerId))
const label = workerStatusLabel(status)
return (
<li key={role.workerId}>
<button
type="button"
data-wk-worker={role.workerId}
aria-pressed={selectedId === role.workerId}
aria-label={`${role.name}, ${label}`}
onClick={() => { if (onSelect) onSelect(role.workerId) }}
>
<span className={Object.hasOwn(DOT, status) ? `wk-dot ${DOT[status]}` : 'wk-dot'} aria-hidden="true" />
<span>{role.name}</span>
<span>{label}</span>
</button>
</li>
)
})}
</ul>
</section>
))}
</div>
)
}
-190
View File
@@ -1,190 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import Button from '../ui/Button'
import Dialog from '../ui/Dialog'
import EmptyState from '../ui/EmptyState'
import { useWorkerTrace, useWorkersFeed } from '../../hooks/useWorkersFeed'
import { indexWorkers, reduceEvents } from '../../lib/workersEvents'
import { pauseLabel } from '../../lib/workersView'
import WorkerCard from './WorkerCard'
import WorkerList from './WorkerList'
import WorkersKpis from './WorkersKpis'
import WorkersSettingsDialog from './WorkersSettingsDialog'
import WorkersStage from './WorkersStage'
const RUNNING_TEXT = 'Läuft'
const PAUSED_TEXT = 'Angehalten: Es wird nichts gesendet und nichts gebaut.'
const PAUSE_FAILED_TEXT = 'Umschalten fehlgeschlagen.'
const NO_REASON_TEXT = 'Grund nicht angegeben'
// Grund der Notbremse für den Satz „Versand gestoppt: <Grund>. …“: getrimmt, ohne abschließende Punkte (sonst entstünde „..“)
function haltReasonOf(reason) {
const text = typeof reason === 'string' ? reason.trim().replace(/[.\s]+$/, '') : ''
return text || NO_REASON_TEXT
}
// Inhalt des Reiters „Hochhaus“: Kopfzeile mit Pausenschalter, Kennzahlen, Hochhaus mit Seitenspalte (Karte und Liste).
// Ohne Auth-Bezug: isAdmin kommt von der Seite. stageRef ist optional (nur der Prüfstand reicht eine durch); ohne eins legt das Board
// selbst eins an, damit pushEvents die Bühne immer erreicht.
export default function WorkersBoard({ api, isAdmin, stageRef }) {
const ownRef = useRef(null)
const ref = stageRef ?? ownRef
const aliveRef = useRef(true)
const sideRef = useRef(null)
const toolbarRef = useRef(null)
const refocusPauseRef = useRef(false) // nach dem Umschalten bekommt der Knopf „Alles anhalten“ / „Fortsetzen“ den Fokus zurück, falls er ihn verlor
const [workers, setWorkers] = useState({})
const [selectedId, setSelectedId] = useState(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [pausing, setPausing] = useState(false)
const [pauseError, setPauseError] = useState('')
const [confirmOpen, setConfirmOpen] = useState(false)
useEffect(() => {
aliveRef.current = true
return () => { aliveRef.current = false }
}, [])
// Neue Ereignisse: Tabelle fortschreiben und der Bühne vorspielen (die Bühne kennt den Rückstau selbst)
const onEvents = useCallback((events) => {
setWorkers((w) => reduceEvents(w, events))
if (ref.current) ref.current.pushEvents(events)
}, [ref])
const { state, error, loaded, refresh } = useWorkersFeed(api, { onEvents })
useEffect(() => {
if (state) setWorkers(indexWorkers(state))
}, [state])
const { trace, loading: traceLoading, error: traceError } = useWorkerTrace(api, selectedId)
// Fokus nach dem Umschalten: Während pausing gilt, ist der Knopf der Leiste gesperrt. Schließt der Dialog im selben Takt, in dem pausing
// beginnt, kann useDialogFocus den Fokus nicht auf den gesperrten Knopf zurückgeben, und er bliebe für immer auf <body>. Deshalb
// wird er erst hier gesetzt, wenn pausing wieder endet (auch nach einem Fehler) und der Knopf wieder bedienbar ist. Hat der Mensch
// inzwischen woanders hin gewechselt (Fokus nicht auf <body>), bleibt es dabei.
useEffect(() => {
if (pausing || !refocusPauseRef.current) return
refocusPauseRef.current = false
const active = document.activeElement
if (active && active !== document.body) return
const button = toolbarRef.current?.querySelector('[data-wk-pause]')
if (button) button.focus()
}, [pausing])
// Hat inzwischen jemand anderes angehalten, entfällt die Frage; sie darf nach einem späteren Fortsetzen nicht wieder auftauchen
const haltedByState = Boolean(state?.paused)
useEffect(() => {
if (haltedByState) setConfirmOpen(false)
}, [haltedByState])
if (!loaded || !state) {
if (error) {
return (
<EmptyState
title="Der Hustler-Dienst ist noch nicht erreichbar"
hint={<p>{error}</p>}
action={<Button type="button" onClick={() => refresh()}>Neu laden</Button>}
/>
)
}
return <div className="loading">Wird geladen …</div>
}
const paused = Boolean(state.paused)
const worker = selectedId && Object.hasOwn(workers, selectedId) ? workers[selectedId] : undefined
// Karte schließen: der Fokus geht zurück auf den Listeneintrag des Workers (sonst fiele er mit dem Knopf „ד ins Leere)
const closeCard = () => {
const id = selectedId
setSelectedId(null)
if (!id || !sideRef.current) return
for (const button of sideRef.current.querySelectorAll('[data-wk-worker]')) {
if (button.getAttribute('data-wk-worker') === id) {
button.focus()
return
}
}
}
// Pause für alle Mitarbeiter: der Dienst bekommt den Zielwert (true = anhalten), danach wird der Zustand sofort neu geholt.
// Der Zielwert wird übergeben und nicht aus dem Zustand abgeleitet: ein offener Dialog darf nie fortsetzen, falls inzwischen
// jemand anderes angehalten hat.
const setPause = async (target) => {
if (pausing) return
refocusPauseRef.current = true
setPausing(true)
setPauseError('')
try {
await api.pause(target)
refresh()
} catch (err) {
if (aliveRef.current) setPauseError(err?.message || PAUSE_FAILED_TEXT)
}
if (aliveRef.current) setPausing(false)
}
// Anhalten betrifft alle Mitarbeiter sofort und fragt deshalb vorher nach; Fortsetzen ist harmlos und geht ohne Rückfrage
const requestPause = () => {
if (paused) setPause(false)
else setConfirmOpen(true)
}
const confirmHalt = () => {
setConfirmOpen(false)
setPause(true)
}
return (
<>
<div className="wk-toolbar" ref={toolbarRef}>
<span className="wk-status">{paused ? PAUSED_TEXT : RUNNING_TEXT}</span>
<span className="grow" />
<Button type="button" onClick={requestPause} disabled={pausing} data-wk-pause>{pauseLabel(paused)}</Button>
{isAdmin && <Button type="button" onClick={() => setSettingsOpen(true)}>Einstellungen</Button>}
</div>
{pauseError && <div className="msg err" role="alert">{pauseError}</div>}
{state.sendingHalted === true && (
<div className="msg err" role="alert">
Versand gestoppt: {haltReasonOf(state.sendingHaltedReason)}. Ein Admin kann ihn in den Einstellungen freigeben.
</div>
)}
{error && <div className="msg warn" role="status">Verbindung gestört: {error} Es wird weiter versucht.</div>}
<WorkersKpis kpis={state.kpis} />
<div className="wk-main">
<WorkersStage ref={ref} state={state} selectedId={selectedId} onSelect={setSelectedId} />
<div className="wk-side" ref={sideRef}>
<WorkerCard
workerId={selectedId}
worker={worker}
trace={trace}
loading={traceLoading}
error={traceError}
onClose={closeCard}
/>
<WorkerList workers={workers} selectedId={selectedId} onSelect={setSelectedId} />
</div>
</div>
{isAdmin && settingsOpen && (
<WorkersSettingsDialog api={api} onClose={() => setSettingsOpen(false)} onSaved={() => refresh()} />
)}
{confirmOpen && !paused && (
<Dialog
title="Alles anhalten?"
onClose={() => setConfirmOpen(false)}
footer={
<>
<span className="grow" />
<Button type="button" onClick={() => setConfirmOpen(false)}>Abbrechen</Button>
<Button variant="primary" type="button" onClick={confirmHalt}>Anhalten</Button>
</>
}
>
<p>Die Worker hören sofort auf, bis jemand fortsetzt.</p>
</Dialog>
)}
</>
)
}
-13
View File
@@ -1,13 +0,0 @@
import Stat from '../ui/Stat'
import { kpiTiles } from '../../lib/workersView'
// Sechs Kennzahlen des Hustler-Dienstes (state.kpis). Fehlende Daten ergeben Striche, nie NaN (kpiTiles).
export default function WorkersKpis({ kpis }) {
return (
<div className="stats wk-kpis">
{kpiTiles(kpis).map((tile) => (
<Stat key={tile.key} label={tile.label} value={tile.value} sub={tile.sub} tone={tile.tone} />
))}
</div>
)
}
-120
View File
@@ -1,120 +0,0 @@
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import Button from '../ui/Button'
import { ZOOM_STEP, mountTower } from './engine/scene'
const FONT = '600 12px "Pixelify Sans"'
const REDUCED_MOTION = '(prefers-reduced-motion: reduce)'
const COARSE_POINTER = '(pointer: coarse)'
// matchMedia, wo es das gibt (sonst null)
const media = (query) => (typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(query) : null)
const NO_STATS = { frames: 0, avgDrawMs: 0, zoom: 1 }
const NO_DEBUG = { pending: 0, agents: [], lift: 'idle', fx: 0 }
// Bühne des Hochhauses: ein Canvas, den die Engine (mountTower) bespielt, und die Zoomknöpfe.
// state = Zustand der Dienst-API (oder null), selectedId = markierte Figur, onSelect(id | null) kommt nur bei Klicks auf die Leinwand.
// Handle über ref: { pushEvents(events), locate(id), stats(), debug() }; vor dem Einhängen der Engine sind alle Aufrufe leer.
// Die Engine wird neu eingehängt, wenn sich „weniger Bewegung“ ändert (Standbild statt Schleife); engineKey zählt die Einhängungen,
// damit Zustand und Markierung an jede neue Engine gehen (auch bei StrictMode: einhängen, abbauen, einhängen).
// Im Standbild kostet jeder Aufruf ein Bild: darum kein setState vor der ersten Einhängung, kein select ohne Änderung und
// kein resize, solange die Breite gleich bleibt.
export default forwardRef(function WorkersStage({ state, selectedId, onSelect }, ref) {
const canvasRef = useRef(null)
const handleRef = useRef(null)
const onSelectRef = useRef(onSelect)
const shownRef = useRef(null) // Markierung, die die aktuelle Engine zeigt (eine neue Engine startet ohne)
const [reduceMotion, setReduceMotion] = useState(() => Boolean(media(REDUCED_MOTION)?.matches))
const [engineKey, setEngineKey] = useState(0)
useEffect(() => { onSelectRef.current = onSelect }, [onSelect])
useImperativeHandle(ref, () => ({
pushEvents: (events) => { if (handleRef.current) handleRef.current.pushEvents(events) },
locate: (id) => (handleRef.current ? handleRef.current.locate(id) : null),
stats: () => (handleRef.current ? handleRef.current.stats() : NO_STATS),
debug: () => (handleRef.current ? handleRef.current.debug() : NO_DEBUG),
}), [])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return undefined
const handle = mountTower(canvas, {
onSelect: (id) => { if (onSelectRef.current) onSelectRef.current(id) },
reducedMotion: reduceMotion,
coarsePointer: Boolean(media(COARSE_POINTER)?.matches),
fontsReady: document.fonts && typeof document.fonts.load === 'function' ? document.fonts.load(FONT) : undefined,
dpr: window.devicePixelRatio || 1,
})
handleRef.current = handle
shownRef.current = null
// mountTower hat schon gemessen; erst eine andere Breite löst resize() aus (der erste Aufruf des Beobachters meldet nur den Ist-Wert)
let width = canvas.getBoundingClientRect().width
const observer = typeof ResizeObserver === 'function'
? new ResizeObserver(() => {
const next = canvas.getBoundingClientRect().width
if (next === width) return
width = next
handle.resize()
})
: null
if (observer) observer.observe(canvas)
// Versteckter Tab: die Szene ruht. Beim Zurückkehren wird die Regie beruhigt (flush), damit nichts aus der Zeit davor nachläuft.
// Die Ereignisse, die inzwischen aufgelaufen sind, holt der Feed selbst und gibt sie danach über pushEvents herein.
const onVisibility = () => {
const hidden = document.hidden
handle.setPaused(hidden)
if (!hidden) handle.flush()
}
document.addEventListener('visibilitychange', onVisibility)
if (document.hidden) handle.setPaused(true)
const motion = media(REDUCED_MOTION)
const onMotion = (e) => setReduceMotion(Boolean(e.matches))
if (motion) motion.addEventListener('change', onMotion)
setEngineKey((k) => k + 1)
return () => {
document.removeEventListener('visibilitychange', onVisibility)
if (motion) motion.removeEventListener('change', onMotion)
if (observer) observer.disconnect()
handle.destroy()
if (handleRef.current === handle) handleRef.current = null
}
}, [reduceMotion])
// engineKey 0 = noch keine Engine eingehängt (der Zähler springt gleich nach dem Einhängen auf 1)
useEffect(() => {
if (engineKey > 0 && state && handleRef.current) handleRef.current.setState(state)
}, [state, engineKey])
useEffect(() => {
const handle = handleRef.current
const wanted = selectedId ?? null
if (!handle || wanted === shownRef.current) return
shownRef.current = wanted
handle.select(wanted)
}, [selectedId, engineKey])
const zoomBy = (factor) => { if (handleRef.current) handleRef.current.zoomBy(factor) }
const resetView = () => { if (handleRef.current) handleRef.current.resetView() }
return (
<div className="wk-stage">
<canvas
ref={canvasRef}
className="wk-canvas"
role="img"
aria-label="Pixel-Hochhaus: Etagen und Räume der Hustler. Auswahl auch über die Liste."
/>
<div className="wk-zoom">
<Button type="button" onClick={() => zoomBy(ZOOM_STEP)} aria-label="Vergrößern">+</Button>
<Button type="button" onClick={() => zoomBy(1 / ZOOM_STEP)} aria-label="Verkleinern">−</Button>
<Button type="button" onClick={resetView} aria-label="Ganzes Hochhaus zeigen">Ganz</Button>
</div>
</div>
)
})
-114
View File
@@ -1,114 +0,0 @@
// Gebäudehülle des Hustler-Hochhauses: Boden und Erde, Dach, Aufzugschacht, Etagenböden und -wände, Schachttüren mit Lampen,
// Etagenschilder rechts außen und der Pausenschleier.
// Portiert aus entwuerfe/scene-turm.js (drawOutside Z. 99-111, drawBuilding Z. 118-172) auf 10 Etagen. Die Stadtsilhouette,
// das Rohrpost-Rohr und die Welle der Vorlage entfallen. Die Räume selbst zeichnet rooms.js (danach, über der Rückwand).
// Etagenmaße kommen aus dem Layout, die Hüllenmaße (Turm x 14..230, Räume x 42..228) stehen hier. Kein React, kein Appwrite,
// kein window/document.
import { px, tint, label, PAL, TEAM } from './sprites.js'
import { WORLD } from './layout.js'
const GROUND_Y = 224 // Bodenlinie = Boden der Tresor-Etage (Erdgeschoss); darunter liegt der Keller
const ROOF_Y = 4 // Oberkante der Dachlinie
const HULL_X0 = 14 // Turm: linke Außenkante
const HULL_W = 216 // Turm: Breite (x 14..230)
const WALL_W = 2 // Wandstärke
const SHAFT_X = 16 // Aufzugschacht x 16..40
const SHAFT_W = 24
const SHAFT_WALL_X = 40 // Trennwand zwischen Schacht und Räumen
const ROOMS_X0 = 42
const ROOMS_W = 186
const DOOR_H = 18 // Schachttür: Sturz 18 über dem Boden
const LAMP_X = 43
const SIGN_DOT_X = 230 // Etagenschild: Punkt rechts neben der Außenwand, Text bei x 234
const SIGN_X = 234
const SIDE_DOOR_X = 225
const C = {
wall: '#aab2c0',
back: '#f3f4f7',
strip: '#e6e9ef',
shaft: '#e4e8ef',
rail: '#d3d8e1',
frame: PAL.metal2,
mute: PAL.mute,
lampOff: '#c3cad6',
ground: PAL.metal,
earth: '#f7f4f0',
speck: '#e8e2da',
post: '#d5dae3',
doorWood: '#a8825f',
}
// Erdflecken: [x, Abstand unter der Bodenlinie]; die mittleren liegen unter dem Keller und sind verdeckt
const SPECKS = [[4, 7], [9, 15], [6, 25], [244, 9], [262, 17], [290, 8], [306, 19], [300, 26], [60, 28], [140, 30], [210, 27]]
// Boden, Erde, Dachlinie mit Fahne und eine Laterne links. Wird vor dem Turm gezeichnet.
export function drawOutside(ctx) {
px(ctx, 0, GROUND_Y + 1, WORLD.w, WORLD.h - GROUND_Y - 1, C.earth)
px(ctx, 0, GROUND_Y, WORLD.w, 1, C.ground)
for (const [x, dy] of SPECKS) px(ctx, x, GROUND_Y + dy, 2, 1, C.speck)
// Laterne
px(ctx, 6, GROUND_Y - 19, 1, 19, C.post)
px(ctx, 4, GROUND_Y - 21, 5, 2, C.post)
px(ctx, 5, GROUND_Y - 19, 3, 1, '#fff3c4')
// Dachlinie und Fahne
px(ctx, HULL_X0, ROOF_Y, HULL_W, 2, C.frame)
px(ctx, 60, 0, 1, 4, C.frame)
px(ctx, 61, 0, 4, 2, TEAM.freigabe)
}
// Hülle, Schacht, Rückwände, Böden, Schachttüren mit Lampen (grün: Kabine steht auf der Etage) und die Etagenschilder.
// t ist im Aufruf vorgesehen (Takt der Szene), die Hülle bewegt sich heute nicht.
export function drawTower(ctx, layout, t, lift) {
const floors = layout.floors
if (floors.length === 0) return
const topY = floors[0].top
const bottom = floors[floors.length - 1].floorY
// Schacht mit Schienen
px(ctx, SHAFT_X, topY, SHAFT_W, bottom - topY, C.shaft)
px(ctx, SHAFT_X, topY, 1, bottom - topY, C.rail)
px(ctx, SHAFT_X + SHAFT_W - 1, topY, 1, bottom - topY, C.rail)
// Rückwand und Bodenstreifen jeder Etage
for (const f of floors) {
px(ctx, ROOMS_X0, f.top, ROOMS_W, f.h, C.back)
px(ctx, ROOMS_X0, f.floorY - 3, ROOMS_W, 3, C.strip)
}
// Trennwand zum Schacht mit Türöffnung je Etage, Sturz und Etagenlampe
px(ctx, SHAFT_WALL_X, topY, WALL_W, bottom - topY, C.back)
floors.forEach((f, i) => {
px(ctx, SHAFT_WALL_X, f.top, WALL_W, f.h - DOOR_H, C.wall)
px(ctx, SHAFT_WALL_X, f.floorY - DOOR_H, WALL_W, 1, C.frame)
px(ctx, LAMP_X, f.floorY - 14, 2, 2, lift.at(i) ? PAL.green : C.lampOff)
})
// Etagenböden, Fundament und Seitenwände
for (const f of floors) px(ctx, HULL_X0, f.floorY, HULL_W, 1, C.wall)
px(ctx, HULL_X0, bottom, HULL_W, 2, C.frame)
px(ctx, HULL_X0, ROOF_Y, WALL_W, bottom + 2 - ROOF_Y, C.wall)
px(ctx, ROOMS_X0 + ROOMS_W, ROOF_Y, WALL_W, bottom + 2 - ROOF_Y, C.wall)
// Seitentür im Erdgeschoss (Tresor-Etage): hier gehen die Scouts in die Stadt
const ground = floors.find((f) => f.id === 'tresor')
if (ground) {
const g = ground.floorY
px(ctx, ROOMS_X0 + ROOMS_W, g - 14, WALL_W, 11, C.back)
px(ctx, ROOMS_X0 + ROOMS_W, g - 3, WALL_W, 3, C.strip)
px(ctx, SIDE_DOOR_X, g - 16, 8, 2, C.doorWood)
px(ctx, SIDE_DOOR_X, g - 14, 2, 14, C.doorWood)
px(ctx, SIDE_DOOR_X + 6, g - 14, 2, 14, C.doorWood)
}
// Schachtkopf mit Rolle
px(ctx, 23, topY, 11, 3, C.frame)
px(ctx, 24, topY + 1, 9, 1, C.ground)
// Etagenschilder rechts außen: Punkt in der Teamfarbe des ersten Raums, Titel aus dem Layout
for (const f of floors) {
px(ctx, SIGN_DOT_X, f.top + 2, 2, 2, TEAM[f.rooms[0]?.team] ?? C.wall)
label(ctx, f.title, SIGN_X, f.top + 1, { size: 11, color: C.mute, weight: 600 })
}
}
// Weißer Schleier mit „Pausiert“ über der ganzen Welt; nur bei look.paused
export function drawPauseVeil(ctx, look) {
if (!look.paused) return
tint(ctx, -2, -2, WORLD.w + 4, WORLD.h + 4, PAL.w, 0.45)
label(ctx, 'Pausiert', WORLD.w / 2, WORLD.h / 2 - 5, { size: 28, color: PAL.ink, weight: 700, align: 'center', bg: PAL.w })
}
-79
View File
@@ -1,79 +0,0 @@
// Kamera des Hochhauses: Ausschnitt der Welt 320 × 256, Zoom 1 bis 3, Verschieben, Umkehr-Transformation, Treffer-Auswahl.
// Das Sichtfeld ist Welt / zoom. Orte in der Ansicht sind Bruchteile 0..1 (links oben = 0, 0), Orte in der Welt logische Pixel.
// Die Funktionen zoomAt, panBy, centerOn und clampCamera ändern die übergebene Kamera und geben dieselbe Kamera zurück.
// Kein React, kein window/document.
import { WORLD } from './layout.js'
export const ZOOM_MIN = 1
export const ZOOM_MAX = 3
const FIT_WIDTH = 560 // ab dieser Breite (CSS-Pixel) passt das ganze Hochhaus bei Zoom 1 lesbar auf die Bühne
const FIT_MAX = 2.2
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v)
// Endliche Zahl oder Ersatzwert: NaN/Infinity aus Zeigerereignissen dürfen die Kamera nie verderben
const num = (v, fallback) => (Number.isFinite(v) ? v : fallback)
// Startzoom für eine Bühne der Breite cssWidth: ab 560 px 1, darunter wachsend bis 2,2; ungültige Breite -> 1
export function fitZoom(cssWidth) {
if (!Number.isFinite(cssWidth) || cssWidth <= 0 || cssWidth >= FIT_WIDTH) return 1
return Math.min(FIT_MAX, FIT_WIDTH / cssWidth)
}
export function createCamera() {
return { zoom: 1, x: 0, y: 0 }
}
// Zoom in 1..3 und Lage so, dass der Ausschnitt in der Welt bleibt
export function clampCamera(cam, world = WORLD) {
cam.zoom = clamp(num(cam.zoom, ZOOM_MIN), ZOOM_MIN, ZOOM_MAX)
cam.x = clamp(num(cam.x, 0), 0, world.w - world.w / cam.zoom)
cam.y = clamp(num(cam.y, 0), 0, world.h - world.h / cam.zoom)
return cam
}
// Ansicht (Bruchteile) -> Welt (logische Pixel)
export function viewToWorld(cam, vx, vy) {
return { x: cam.x + vx * (WORLD.w / cam.zoom), y: cam.y + vy * (WORLD.h / cam.zoom) }
}
// Welt -> Ansicht; Gegenstück zu viewToWorld
export function worldToView(cam, x, y) {
return { vx: (x - cam.x) / (WORLD.w / cam.zoom), vy: (y - cam.y) / (WORLD.h / cam.zoom) }
}
// Zoom um factor; der Weltpunkt unter (fx, fy) bleibt stehen, solange der Rand nicht klemmt
export function zoomAt(cam, factor, fx, fy) {
const f = factor > 0 ? num(factor, 1) : 1
const px = num(fx, 0.5)
const py = num(fy, 0.5)
const w = viewToWorld(cam, px, py)
cam.zoom = clamp(cam.zoom * f, ZOOM_MIN, ZOOM_MAX)
cam.x = w.x - px * (WORLD.w / cam.zoom)
cam.y = w.y - py * (WORLD.h / cam.zoom)
return clampCamera(cam)
}
// Verschieben um Bruchteile der Ansicht (0,5 = halbe Bildbreite)
export function panBy(cam, dxFrac, dyFrac) {
cam.x += num(dxFrac, 0) * (WORLD.w / cam.zoom)
cam.y += num(dyFrac, 0) * (WORLD.h / cam.zoom)
return clampCamera(cam)
}
// Weltpunkt (x, y) in die Mitte der Ansicht
export function centerOn(cam, x, y) {
cam.x = x - WORLD.w / cam.zoom / 2
cam.y = y - WORLD.h / cam.zoom / 2
return clampCamera(cam)
}
// Oberste Figur an (x, y) in Weltkoordinaten; boxes in Zeichenreihenfolge (die letzte liegt oben), pad vergrößert jeden Kasten
export function pickAgent(boxes, x, y, pad = 0) {
if (!Array.isArray(boxes)) return null
for (let i = boxes.length - 1; i >= 0; i--) {
const b = boxes[i]?.box
if (b && x >= b.x - pad && x <= b.x + b.w + pad && y >= b.y - pad && y <= b.y + b.h + pad) return boxes[i].id
}
return null
}
-673
View File
@@ -1,673 +0,0 @@
// Regie des Hustler-Hochhauses: Agenten, Snapshot ohne Animation, Posen, Leerlauf, Schritte (Übergaben mit Aufzugfahrten,
// Effekte, Sprechblasen), Rückstau-Kappung, Wachhund und Zeichnen.
// Portiert aus entwuerfe/scene-turm.js (Agenten Z. 596-625, Schritte Z. 416-464, Update Z. 864-905, Zeichnen Z. 925-960).
// Kein React, kein Appwrite, kein window/document.
import { ROLES, hatForWorker } from '../../../lib/workersRoster.js'
import { indexWorkers, reduceEvents, eventToSteps, cutBubble } from '../../../lib/workersEvents.js'
import { homeOf } from './layout.js'
import { LIFT } from './lift.js'
import { spawnFx } from './fx.js'
import { trophyPaging } from './rooms.js'
import { drawWorker, drawBubble, drawSideBubble, drawAlertMark, drawPaper } from './sprites.js'
export const BACKLOG_KEEP = 6 // je pushEvents animieren nur die neuesten so viele Gruppen, ältere wirken still
export const GROUPS_PER_AGENT = 4 // je Worker höchstens so viele wartende Gruppen
export const IDLE_COFFEE_S = 25 // so lange trinkt ein Worker im Leerlauf Kaffee, dann schläft er
export const STEP_TIMEOUT_S = 45 // ein Schritt dauert höchstens so lange, dann greift der Wachhund
export const BUBBLE_MAX = 3 // höchstens so viele Sprechblasen gleichzeitig
const PAGE_S = 6 // Pokal-Seiten blättern alle 6 Sekunden
const POP_S = 1.2 // so lange blinkt ein neuer Pokal
const BUBBLE_S = 2.2 // Standarddauer einer Sprechblase
const LANE = -2 // wer am Platz steht, wird 2 Einheiten höher gezeichnet (hinter dem Tisch)
const LANE_SPEED = 14 // Einheiten je Sekunde, mit der die Spur wechselt
const HOME_EPS = 0.5 // so nah am Heimatplatz gilt ein Worker als am Platz
const ALERT_H = 9 // Höhe des „!“ mit Umriss (sprites.js)
const WALK_SPEED = 46 // Einheiten je Sekunde (Vorlage WALK 38, schnell 46)
const ARRIVE_EPS = 0.5 // so nah am Ziel gilt ein Weg als zu Ende
const PICKUP_S = 0.4 // so lange dauert es, das Dokument aufzunehmen
const SAY_S = 1.2 // so lange wartet die Gruppe nach einer Sprechblase, bevor der nächste Schritt kommt
const THANKS_S = 1.0 // so lange bleibt der Giver nach der Übergabe beim Empfänger stehen
const CHEER_S = 2 // so lange jubelt ein Worker
const VAULT_FLASH_S = 0.7 // Tresor-Blitz, wenn eine Münze ankommt
const BESIDE_DX = 12 // Abstand zum Empfänger bei der Übergabe
const CAB_DY = 7 // Fahrgast: obere Kante des Körpers 7 unter der Kabinenoberkante (wie in lift.js)
const MAX_STEPS_PER_FRAME = 32 // so viele Schritte endet ein Worker höchstens in einem Bild (Schutz vor Endlosschleifen)
// Blickrichtung am Heimatplatz (Vorlage FACE); alle anderen blicken nach rechts
const FACE_LEFT = new Set(['ali', 'hanna', 'sven', 'lea', 'paul', 'clara'])
const faceOf = (id) => (FACE_LEFT.has(id) ? -1 : 1)
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v)
// Tiefe für die Zeichenreihenfolge: wer weiter unten steht, wird später (davor) gezeichnet
const depthOf = (a) => a.y + a.lane + a.bob + 13 * a.scale
const byDepth = (agents) => [...agents].sort((p, q) => depthOf(p) - depthOf(q))
// Pose nach Status: nicht angeschlossen schläft, Leerlauf trinkt Kaffee und schläft nach IDLE_COFFEE_S, Arbeit tippt
function poseFor(a) {
if (a.grey) return 'sleep'
if (a.status === 'leerlauf') return a.idleT >= IDLE_COFFEE_S ? 'sleep' : 'coffee'
if (a.status === 'arbeitet') return 'type'
return 'stand'
}
// Läuft für den Worker eine Gruppe? Dann bestimmt die Gruppe die Pose, nicht der Status
const busy = (a) => a.groups > 0
// Steht der Worker an seinem Platz? (kein Schritt, nicht im Aufzug)
const atHome = (a) => a.frame === 0 && Math.abs(a.x - a.home.x) < HOME_EPS && Math.abs(a.y - a.home.y) < HOME_EPS
// Spur: am Platz -2 (Otto steht immer vorn), unterwegs 0
const laneTarget = (a) => (a.id !== 'otto' && atHome(a) ? LANE : 0)
// Zähler als ganze Zahl >= 0; alles andere (NaN, Text, null, fehlend) ist kein Wert
const kpiOf = (kpis, key) => (isRecord(kpis) && Number.isFinite(kpis[key]) ? Math.max(0, Math.floor(kpis[key])) : null)
// Läuft auf (tx, ty) zu (Vorlage stepTowards); speed in Einheiten je Sekunde; true = angekommen (dann steht die Figur: frame 0)
function stepTowards(a, tx, ty, speed, dt) {
const dx = tx - a.x
const dy = ty - a.y
const dist = Math.hypot(dx, dy)
if (dist < ARRIVE_EPS) {
a.x = tx
a.y = ty
a.frame = 0
return true
}
const step = Math.min(dist, speed * dt)
a.x += (dx / dist) * step
a.y += (dy / dist) * step
if (Math.abs(dx) > 0.3) a.facing = dx < 0 ? -1 : 1
a.walkT += dt
a.frame = 1 + (Math.floor(a.walkT * 8) % 2)
return false
}
/* createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Math.random })
→ { agents, byId, applyState(state), pushEvents(events), update(dt), flush(), reset(), pending(), debug(), boxes(), bubble(agent, text, dur) }
Ein Agent je Rolle (Rollen ohne Heimatplatz im Layout entfallen). applyState übernimmt einen Dienst-Zustand: der erste Snapshot setzt
alle Agenten auf ihren Platz, spätere bewegen nie und ändern Pose und Aufgabe nur bei Agenten ohne laufende Gruppe (groups = 0); was dabei
ausfällt, holt die Regie am Ende der letzten Gruppe aus der Tabelle nach. Die Tabelle (workers) bleibt dauerhaft: Snapshot, fortgeschrieben mit
reduceEvents.
pushEvents(events) übersetzt jedes Ereignis über eventToSteps in eine Gruppe von Schritten. Besitzer der Gruppe ist der Absender des ersten
give, sonst die workerId (wenn es den Worker gibt), sonst Otto. Die neuesten BACKLOG_KEEP Gruppen laufen als Animation in der Warteschlange
ihres Besitzers (agent.q, agent.groups), alle älteren wirken still (nur Aufgabe und Pose); still ist auch jede Gruppe eines grauen Besitzers
und jede, die der Besitzer wegen GROUPS_PER_AGENT nicht mehr aufnimmt. Vor dem ersten Snapshot tut pushEvents nichts.
update(dt) tut bei look.paused nichts und läuft sonst ohne Obergrenze für dt (flush ruft es mit 0,25).
Wachhund: ein Schritt, der länger als STEP_TIMEOUT_S läuft, bricht die Gruppe ab (Aufzug freigeben, Türzähler zurücknehmen, Agent heim).
flush() spielt alles schnell zu Ende und setzt dann alles zurück, reset() setzt sofort zurück (Warteschlangen leer, Aufzug und Effekte
zurückgesetzt, alle Agenten heim). pending() = wartende und laufende Gruppen über alle Agenten. rnd streut die Wipp-Phase. */
export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Math.random }) {
const agents = []
const byId = Object.create(null)
const door = new Array(layout.floors.length).fill(0) // Wartende vor dem Aufzug je Etage (Türzähler)
const counted = new Map() // Agent -> Etage, solange er im Türzähler mitgezählt wird
const lagging = new Set() // Kennungen, bei denen Aufgabe und Pose noch aus der Tabelle nachzuholen sind
let workers = {} // Tabelle nach workerId: letzter Snapshot, fortgeschrieben mit den Ereignissen (indexWorkers, reduceEvents)
let first = true // der erste Snapshot stellt alle Agenten an ihren Platz
let prevDeals = null // dealsMonth des vorigen Snapshots; null = noch keiner gesehen
let clock = 0 // Regie-Zeit in Sekunden (nur für das Wippen)
for (const role of roles) {
const home = homeOf(layout, role.workerId)
if (!home) continue
const a = {
id: role.workerId,
name: role.name,
hat: hatForWorker(role.workerId),
scale: role.workerId === 'otto' ? 2 : 1,
x: home.x,
y: home.y,
floor: home.floor,
facing: faceOf(role.workerId),
frame: 0,
pose: 'stand',
carrying: false,
stackN: 0,
say: null,
bubT: 0,
grey: false,
alert: false,
status: 'wartet',
task: '',
counters: {},
lane: 0,
bob: 0,
idleT: 0,
cheer: 0,
q: [], // wartende und laufende Gruppen { steps, dead }, vorne die laufende
cur: null, // laufender Schritt
stepT: 0, // so lange läuft der Schritt schon (Wachhund)
groups: 0,
box: null,
home,
walkT: 0, // Laufzeit für den Schrittwechsel der Beine
dy: home.y - layout.floors[home.floor].floorY, // Standhöhe relativ zum Boden (Otto steht höher)
ph: rnd() * 6, // Phase des Wippens: nicht alle im Gleichschritt
}
a.lane = laneTarget(a)
agents.push(a)
byId[a.id] = a
}
const entryOf = (a) => (Object.hasOwn(workers, a.id) ? workers[a.id] : null)
const standY = (a, floor) => layout.floors[floor].floorY + a.dy
const pending = () => agents.reduce((n, a) => n + a.groups, 0)
// Auf den Heimatplatz stellen (erster Snapshot, Abbruch, reset)
function placeHome(a) {
a.x = a.home.x
a.y = a.home.y
a.floor = a.home.floor
a.facing = faceOf(a.id)
a.frame = 0
a.carrying = false
a.stackN = 0
a.bob = 0
a.lane = laneTarget(a)
}
// grey- und busyRooms aus den Agenten neu bilden (die Mengen bleiben dieselben Objekte)
function syncRooms() {
look.greyRooms.clear()
look.busyRooms.clear()
for (const a of agents) {
if (a.grey) look.greyRooms.add(a.home.room)
else if (a.status === 'arbeitet') look.busyRooms.add(a.home.room)
}
}
// Nur den Status aus einem Eintrag der Tabelle (oder null = fehlt im Zustand) übernehmen
function adoptStatus(a, w) {
a.status = w ? w.status : 'grau'
a.grey = a.status === 'grau'
a.alert = a.status === 'fehler'
}
// Eintrag der Tabelle auf den Agenten übertragen; Aufgabe und Pose erst, wenn keine Gruppe läuft (sonst später per catchUp)
function adopt(a, w) {
adoptStatus(a, w)
a.counters = w ? w.counters : {}
if (busy(a)) {
lagging.add(a.id)
return
}
lagging.delete(a.id)
a.task = w ? w.task : ''
a.pose = poseFor(a)
}
function applyKpis(kpis) {
const pult = kpiOf(kpis, 'pultOpen')
const deals = kpiOf(kpis, 'dealsMonth')
const bot = kpiOf(kpis, 'botLeads')
if (pult !== null) look.pultOpen = pult
if (bot !== null) look.botLeads = bot
if (deals !== null) {
if (prevDeals !== null && deals > prevDeals) look.trophyPop = POP_S
look.dealsMonth = deals
prevDeals = deals
}
}
function applyState(state) {
workers = indexWorkers(state)
for (const a of agents) adopt(a, entryOf(a))
if (first) {
first = false
for (const a of agents) placeHome(a)
}
syncRooms()
applyKpis(isRecord(state) ? state.kpis : null)
look.paused = isRecord(state) && !!state.paused
}
// Sprechblase zeigen; höchstens BUBBLE_MAX Worker gleichzeitig (der Sprecher selbst zählt nicht mit), sonst stillschweigend verworfen
function bubble(a, text, dur = BUBBLE_S) {
const str = cutBubble(text)
if (!a || str === '' || !agents.includes(a)) return false
let others = 0
for (const o of agents) if (o.say && o !== a) others += 1
if (others >= BUBBLE_MAX) return false
a.say = str
a.bubT = dur
return true
}
// ---------- Schritte ----------
// Ort für Effekte: Worker (Kopfoberkante, Mitte) oder Platz aus layout.spots; unbekannt oder fehlend -> null
function placeOf(place) {
if (typeof place !== 'string') return null
if (Object.hasOwn(byId, place)) {
const a = byId[place]
return { x: a.x + 6 * a.scale, y: a.y + a.lane - 2 * a.scale }
}
return Object.hasOwn(layout.spots, place) ? layout.spots[place] : null
}
// Aus dem Türzähler austragen, aber nur, wenn der Agent noch mitgezählt wird (vor dem Einsteigen)
function uncount(a) {
if (!counted.has(a)) return
door[counted.get(a)] -= 1
counted.delete(a)
}
// Gruppe gilt als abgebrochen: alle weiteren Schritte entfallen, der Agent steht sofort wieder an seinem Platz
function abort(a, group) {
uncount(a)
group.dead = true
group.steps.length = 0
placeHome(a)
return true
}
const guarded = (group, fn) => (a, dt) => (group.dead ? true : fn(a, dt))
const instant = (fn) => (a) => {
fn(a)
return true
}
// Wartet sec Sekunden; onStart läuft einmal beim ersten Aufruf
function timed(sec, onStart) {
let t = 0
let begun = false
return (a, dt) => {
if (!begun) {
begun = true
if (onStart) onStart(a)
}
t += dt
return t >= sec
}
}
// Aufzug fahren von Etage fromF nach toF (der Agent steht auf fromF); Algorithmus der Vorlage rideStep, erweitert um Abbruch
function rideStep(group, fromF, toF) {
let ph = 0
let req = null
let sx = 0
return (a, dt) => {
if (ph === 0) {
sx = LIFT.slot0 + LIFT.slotStep * door[fromF]
door[fromF] += 1
counted.set(a, fromF)
ph = 1
}
if (ph === 1) {
if (!stepTowards(a, sx, standY(a, fromF), WALK_SPEED, dt)) return false
a.facing = -1
req = lift.request(a, fromF, toF) || { st: 'cancelled' }
ph = 2
}
if (req.st === 'cancelled') return abort(a, group)
if (ph === 2) {
if (req.st !== 'open') return false
uncount(a)
ph = 3
}
if (ph === 3) {
if (!stepTowards(a, LIFT.cabWx, standY(a, fromF), WALK_SPEED, dt)) return false
// der Fahrgast steht vor dem Einsteigen schon in der Kabine
a.facing = 1
a.frame = 0
a.x = LIFT.cabWx
a.y = lift.y + CAB_DY
req.st = 'boarded'
ph = 4
}
if (ph === 4) {
if (req.st !== 'arrived') return false
a.floor = toF
a.x = LIFT.cabWx
a.y = standY(a, toF)
ph = 5
}
if (!stepTowards(a, LIFT.exitX, standY(a, toF), WALK_SPEED, dt)) return false
req.st = 'out'
return true
}
}
// Neben den Empfänger laufen (links oder rechts, je nach Seite des Gebers), zu ihm hinblicken
function besideStep(r) {
let tx = null
let face = 1
return (a, dt) => {
if (tx === null) {
const fromLeft = a.x < r.home.x
tx = fromLeft ? r.home.x - BESIDE_DX : r.home.x + BESIDE_DX * r.scale
face = fromLeft ? 1 : -1
a.floor = r.home.floor
}
if (!stepTowards(a, tx, standY(a, r.home.floor), WALK_SPEED, dt)) return false
a.facing = face
return true
}
}
// Zurück zum Platz, dann Blickrichtung wie am Platz
function homeStep() {
return (a, dt) => {
if (!stepTowards(a, a.home.x, a.home.y, WALK_SPEED, dt)) return false
a.facing = faceOf(a.id)
a.carrying = false
a.stackN = 0
return true
}
}
// Übergabe: Dokument aufnehmen, (Aufzug,) zum Empfänger, Danke, (Aufzug,) heim. Ohne Empfänger, bei sich selbst oder bei grauem Empfänger entfällt sie.
function giveSteps(op, owner, group) {
const r = byId[op.to]
if (!r || r === owner || r.grey) return []
const fromF = owner.home.floor
const toF = r.home.floor
const steps = [
timed(PICKUP_S, (a) => {
a.pose = 'stand'
a.carrying = true
a.stackN = 1
}),
]
if (fromF !== toF) steps.push(rideStep(group, fromF, toF))
steps.push(
besideStep(r),
timed(THANKS_S, (a) => {
a.carrying = false
a.stackN = 0
if (!r.grey) bubble(r, 'Danke!')
}),
)
if (fromF !== toF) steps.push(rideStep(group, toF, fromF))
steps.push(homeStep())
return steps
}
// Effekt starten (ohne zu warten); fehlt Start oder Ziel im Layout, entfällt er
function fxStep(op) {
return instant(() => {
const from = placeOf(op.at)
const hasTo = Object.hasOwn(op, 'to')
const to = hasTo ? placeOf(op.to) : op.fx === 'drawer' ? layout.spots.archiv : null
if (!from || ((hasTo || op.fx === 'drawer') && !to)) return
spawnFx(fx, op.fx, {
from,
to: to || undefined,
delay: op.delay,
onEnd: op.fx === 'coin' ? () => { look.vaultFlash = VAULT_FLASH_S } : undefined,
})
})
}
// Sprechblase zeigen und kurz warten (nur, wenn sie wirklich erscheint)
function sayStep(op) {
let hold = -1
let t = 0
return (a, dt) => {
if (hold < 0) {
const who = byId[op.who]
hold = who && !who.grey && bubble(who, op.text) ? SAY_S : 0
}
t += dt
return t >= hold
}
}
// Schritte eines Ops; Ziel der Aufgabe, Pose, Blase und des Jubels ist op.who (nicht unbedingt der Besitzer der Gruppe)
function stepsOf(op, owner, group) {
const who = byId[op.who]
switch (op.op) {
case 'task':
return [instant(() => {
if (!who || who.grey) return
if (who !== owner && busy(who)) lagging.add(who.id)
else who.task = op.text
})]
case 'pose':
return [instant(() => {
if (!who || who.grey || (who !== owner && busy(who))) return
who.pose = op.pose
who.idleT = 0
})]
case 'cheer':
return [instant(() => {
if (who && !who.grey) who.cheer = CHEER_S
})]
case 'say':
return [sayStep(op)]
case 'fx':
return [fxStep(op)]
case 'give':
return giveSteps(op, owner, group)
default:
return []
}
}
// Gruppe an die Warteschlange des Besitzers hängen; zu Beginn weckt sie ihn (wer schläft, spricht und läuft nicht im Schlaf)
function queueGroup(owner, ops) {
const group = { steps: [], dead: false }
group.steps.push((a) => {
const pose = poseFor(a)
if (a.grey) group.dead = true
else a.pose = pose === 'sleep' ? 'stand' : pose
return true
})
for (const op of ops) group.steps.push(...stepsOf(op, owner, group).map((fn) => guarded(group, fn)))
owner.q.push(group)
owner.groups += 1
}
// Stille Gruppe: nur Aufgabe und Pose, sofort. Bei einem Worker mit laufender Gruppe holt die Tabelle es am Gruppenende nach.
function runQuiet(ops) {
for (const op of ops) {
if (op.op !== 'task' && op.op !== 'pose') continue
const who = byId[op.who]
if (!who || who.grey) continue
if (busy(who)) lagging.add(who.id)
else if (op.op === 'task') who.task = op.text
else {
who.pose = op.pose
who.idleT = 0
}
}
}
// Besitzer der Gruppe: Absender des ersten give, sonst die workerId (wenn es den Worker gibt), sonst Otto
function ownerOf(e, ops) {
const give = ops.find((s) => s.op === 'give')
for (const id of [give && give.from, e.workerId, 'otto']) {
if (typeof id === 'string' && Object.hasOwn(byId, id)) return byId[id]
}
return null
}
function pushEvents(events) {
if (first || !Array.isArray(events) || events.length === 0) return
workers = reduceEvents(workers, events)
for (const a of agents) adoptStatus(a, entryOf(a))
syncRooms()
const groups = []
for (const e of events) {
const ops = eventToSteps(e)
const owner = ops.length > 0 ? ownerOf(e, ops) : null
if (owner) groups.push({ owner, ops })
}
// Animiert werden die neuesten BACKLOG_KEEP Gruppen; nimmt ein Besitzer nicht alle auf, behält er die neuesten
const animated = new Set()
const claimed = new Map()
for (let i = groups.length - 1; i >= 0 && i >= groups.length - BACKLOG_KEEP; i--) {
const { owner } = groups[i]
const held = owner.groups + (claimed.get(owner) || 0)
if (owner.grey || held >= GROUPS_PER_AGENT) continue
claimed.set(owner, (claimed.get(owner) || 0) + 1)
animated.add(groups[i])
}
for (const g of groups) if (!animated.has(g)) runQuiet(g.ops)
for (const g of groups) if (animated.has(g)) queueGroup(g.owner, g.ops)
}
// ---------- Ablauf je Bild ----------
// Aufgabe und Pose aus der Tabelle nachholen (nach dem Ende der letzten Gruppe)
function catchUp(a) {
adopt(a, entryOf(a))
}
// Nächsten Schritt der laufenden Gruppe holen; ist sie zu Ende, zählt sie ab und die nächste beginnt
function nextStep(a) {
a.stepT = 0
a.cur = null
while (a.q.length > 0) {
const group = a.q[0]
if (group.steps.length > 0) {
a.cur = group.steps.shift()
return
}
a.q.shift()
a.groups = Math.max(0, a.groups - 1)
if (a.groups === 0 && lagging.has(a.id)) catchUp(a)
}
}
// Wachhund: der Schritt dauert zu lange, Aufzug freigeben, Türzähler zurücknehmen, Agent heim, Gruppe beenden
function watchdog(a) {
lift.cancel(a)
if (a.q.length > 0) abort(a, a.q[0])
else uncount(a)
nextStep(a)
}
function runSteps(a, dt) {
if (!a.cur) nextStep(a)
for (let n = 0; a.cur && n < MAX_STEPS_PER_FRAME; n++) {
if (a.cur(a, dt)) {
nextStep(a)
continue
}
a.stepT += dt
if (a.stepT > STEP_TIMEOUT_S) watchdog(a)
break
}
}
function stepAgent(a, dt) {
runSteps(a, dt)
a.idleT = a.status === 'leerlauf' ? a.idleT + dt : 0
if (!busy(a)) a.pose = poseFor(a)
a.lane += clamp(laneTarget(a) - a.lane, -dt * LANE_SPEED, dt * LANE_SPEED)
a.bob = 0
if (a.cheer > 0) {
a.cheer = Math.max(0, a.cheer - dt)
a.bob = Math.floor(clock * 8) % 2 ? -2 : 0
} else if (a.pose === 'type' && atHome(a)) {
a.bob = Math.floor(clock * 3 + a.ph) % 2 ? -1 : 0
}
if (a.bubT > 0) {
a.bubT -= dt
if (a.bubT <= 0) {
a.bubT = 0
a.say = null
}
}
}
// Ein Bild weiterschalten, ohne auf Pause oder ungültiges dt zu achten (update und flush)
function advance(dt) {
clock += dt
for (const a of agents) stepAgent(a, dt)
lift.update(dt)
fx.update(dt)
look.trophyPop = Math.max(0, look.trophyPop - dt)
look.vaultFlash = Math.max(0, look.vaultFlash - dt)
look.pageT += dt
if (look.pageT >= PAGE_S) {
look.pageT %= PAGE_S
look.trophyPage = (look.trophyPage + 1) % trophyPaging(look.dealsMonth, 0).pages
}
}
function update(dt) {
if (look.paused || !Number.isFinite(dt) || dt <= 0) return
advance(dt)
}
// Sofort ruhig: Warteschlangen leer, Aufzug und Effekte zurückgesetzt, alle Agenten heim, Blasen weg
function reset() {
for (const a of agents) {
a.q.length = 0
a.cur = null
a.stepT = 0
a.groups = 0
a.say = null
a.bubT = 0
a.cheer = 0
placeHome(a)
if (lagging.has(a.id)) catchUp(a)
a.pose = poseFor(a)
}
door.fill(0)
counted.clear()
lift.reset()
fx.clear()
}
// Alles schnell zu Ende spielen (höchstens 2000 Bilder à 0,25 s), danach zurücksetzen
function flush() {
for (let i = 0; i < 2000 && pending() > 0; i++) advance(0.25)
reset()
}
// Trefferflächen der zuletzt gezeichneten Figuren in Zeichenreihenfolge (die letzte liegt oben), für pickAgent
function boxes() {
return byDepth(agents).filter((a) => a.box).map((a) => ({ id: a.id, box: a.box }))
}
const debug = () => ({ door: door.slice(), groups: pending() })
return { agents, byId, applyState, pushEvents, update, flush, reset, pending, debug, boxes, bubble }
}
// ---------- Zeichnen ----------
// Eine Figur mit Stapel-Papieren und „!“; merkt die Trefferfläche in agent.box
function drawAgent(ctx, a, selected, t) {
const sc = a.scale
const x = Math.round(a.x)
const y = Math.round(a.y + a.lane + a.bob)
if (a.carrying && a.stackN > 1) {
const bx = x + (a.facing === -1 ? -2 : 8) * sc
const by = y + 6 * sc + (a.frame === 1 ? -1 : 0)
drawPaper(ctx, bx + 1, by - 1)
if (a.stackN > 4) drawPaper(ctx, bx + 2, by - 2)
}
a.box = drawWorker(ctx, x, y, { hat: a.hat, frame: a.frame, facing: a.facing, carrying: a.carrying, scale: sc, selected, pose: a.pose, grey: a.grey, t })
// Das „!“ steht über dem Hut, bei Auswahl über dem Pfeil (Anker: Mitte der Unterkante)
if (a.alert) drawAlertMark(ctx, x + 6 * sc, y - 2 * sc - (selected ? 6 * sc : 0))
}
// Blase eines Agenten: Otto an der Oberkante der Welt seitlich, alle anderen mit der Spitze auf der Kopfoberkante
// (Hut, wie „!“ und Effekte), bei Auswahl und Fehler darüber, damit sie Pfeil und „!“ nicht verdeckt
function drawSay(ctx, a, selected) {
const sc = a.scale
const y = a.y + a.lane + a.bob
if (a.id === 'otto') {
drawSideBubble(ctx, a.say, { leftX: a.x, rightX: a.x + 12 * sc, y: y + 3 })
} else if (a.x > -6 && a.x < 326) {
const lift = (selected ? 6 * sc : 0) + (a.alert ? ALERT_H + 1 : 0)
drawBubble(ctx, a.x + 6 * sc, y - 2 * sc - lift, a.say)
}
}
/* Zeichnet alle Figuren von hinten nach vorn (nach depthOf sortiert), danach die Sprechblasen.
Setzt agent.box (für boxes()), zeichnet Auswahlpfeil bei selectedId und „!“ bei Fehler. t = Zeit in Sekunden. */
export function drawAgents(ctx, director, selectedId, t) {
const order = byDepth(director.agents)
for (const a of order) drawAgent(ctx, a, a.id === selectedId, t)
for (const a of order) if (a.say) drawSay(ctx, a, a.id === selectedId)
}
-188
View File
@@ -1,188 +0,0 @@
// Effekt-System des Hustler-Hochhauses: kurzlebige Zeichnungen (Papier, Brief, Münze, Stempel, Funkeln, Warnzeichen),
// die in Weltkoordinaten von einem Punkt zum anderen laufen. Portiert aus entwuerfe/scene-turm.js (addFx/updateFx/drawFx/envelope).
// Zeit läuft nur über update(dt) der Szene, nie über setTimeout (pausierte Tabs laufen so nicht vor).
// Kein React, kein Appwrite, kein window/document; gezeichnet wird ausschließlich über die Engine-Helfer (px, drawPaper, ...).
import { px, PAL, drawPaper, drawAlertMark } from './sprites.js'
import { drawCoin, drawStamp } from './props.js'
export const FX_NAMES = Object.freeze(['paper', 'envelope', 'coin', 'stampOk', 'stampNo', 'sparkle', 'alert', 'drawer'])
export const MAX_FX = 80
// ---------- Liste der laufenden Effekte ----------
/* createFx() → { add(life, draw, onEnd?, delay = 0), update(dt), draw(ctx), clear(), size() }
add: Lebensdauer und Verzögerung in Sekunden; draw(ctx, progress 0..1, effect) mit effect = { t, life } (t = Sekunden seit dem Start).
onEnd läuft genau einmal beim Ablauf. Ist die Liste mit MAX_FX Effekten voll, wird der neue verworfen und sein onEnd sofort gerufen.
clear() verwirft alles ohne onEnd. size() zählt auch Effekte, die noch auf ihre Verzögerung warten. */
export function createFx() {
let list = []
return {
add(life, draw, onEnd, delay = 0) {
if (list.length >= MAX_FX) {
if (typeof onEnd === 'function') onEnd()
return
}
list.push({
t: Number.isFinite(delay) && delay > 0 ? -delay : 0, // negativ = wartet noch auf den Start
life: Number.isFinite(life) && life > 0 ? life : 0,
draw,
onEnd,
})
},
update(dt) {
if (!Number.isFinite(dt) || dt <= 0) return
const alive = []
const ended = []
for (const f of list) {
f.t += dt
if (f.t >= f.life) ended.push(f)
else alive.push(f)
}
// Erst die Liste ersetzen, dann die Rückrufe: Ein onEnd darf neue Effekte anlegen oder clear() rufen.
list = alive
let failure = null
for (const f of ended) {
if (typeof f.onEnd !== 'function') continue
try {
f.onEnd()
} catch (err) {
if (!failure) failure = { err } // die übrigen onEnd laufen trotzdem, der erste Fehler wird danach geworfen
}
}
if (failure) throw failure.err
},
draw(ctx) {
for (const f of list) {
if (f.t < 0 || typeof f.draw !== 'function') continue
f.draw(ctx, f.life > 0 ? Math.min(1, f.t / f.life) : 1, f)
}
},
clear() {
list = []
},
size() {
return list.length
},
}
}
// ---------- Zeichnungen ----------
// Brief 7 × 5 (x, y = linke obere Ecke)
const ENVELOPE_COLOR = '#7a5cd6'
function drawEnvelope(ctx, x, y) {
px(ctx, x, y, 7, 5, ENVELOPE_COLOR)
px(ctx, x + 1, y + 1, 5, 3, PAL.w)
px(ctx, x + 1, y + 1, 1, 1, ENVELOPE_COLOR)
px(ctx, x + 2, y + 2, 1, 1, ENVELOPE_COLOR)
px(ctx, x + 3, y + 2, 1, 1, ENVELOPE_COLOR)
px(ctx, x + 4, y + 2, 1, 1, ENVELOPE_COLOR)
px(ctx, x + 5, y + 1, 1, 1, ENVELOPE_COLOR)
}
// Vier Goldpixel in den Ecken wachsen nach außen, ein heller Pixel steht oben (um (x, y) als Mitte)
function drawSparkle(ctx, x, y, p) {
const s = Math.round(p * 6)
px(ctx, x - s - 1, y - s - 2, 1, 1, PAL.gold)
px(ctx, x + s + 1, y - s - 2, 1, 1, PAL.gold)
px(ctx, x - s - 1, y + s + 2, 1, 1, PAL.gold)
px(ctx, x + s + 1, y + s + 2, 1, 1, PAL.gold)
px(ctx, x, y - s - 4, 1, 1, '#fff3c4')
}
// Punkt auf dem Bogen von a nach b, in ganzen Einheiten (Pixel-Optik); high = Höhe des Bogens über der Geraden
function arcPoint(a, b, p, high) {
return {
x: Math.round(a.x + (b.x - a.x) * p),
y: Math.round(a.y + (b.y - a.y) * p - Math.sin(p * Math.PI) * high),
}
}
// Ziel um len Einheiten vor b (auf der Strecke a nach b); liegt b näher als len, bleibt der Start
function shortenedEnd(a, b, len) {
const dx = b.x - a.x
const dy = b.y - a.y
const dist = Math.hypot(dx, dy)
if (dist <= len) return a
return { x: b.x - (dx / dist) * len, y: b.y - (dy / dist) * len }
}
// Der Stempel hüpft dreimal über dem Punkt, die Sprünge werden flacher, am Ende liegt er auf
function stampDraw(a, ok) {
return (ctx, p) => {
const hop = Math.round(Math.abs(Math.sin(p * Math.PI * 3)) * 6 * (1 - p))
drawStamp(ctx, Math.round(a.x) - 3, Math.round(a.y) - 5 - hop, ok)
}
}
/* Je Effektname: Lebensdauer in Sekunden und eine Fabrik, die aus Start (a) und Ziel (b) die Zeichenfunktion baut.
Papier, Brief und Münze sind auf ihren Punkt zentriert (Mitte des Sprites). Stempel und Warnzeichen brauchen nur a und stehen mit
der Unterkante darauf (wie die Spitze der Sprechblase: die Regie gibt die Kopfoberkante des Workers), das Funkeln ist um a zentriert. */
const KINDS = {
paper: {
life: 0.9,
make: (a, b) => (ctx, p) => {
const q = arcPoint(a, b, p, 8)
drawPaper(ctx, q.x - 3, q.y - 3)
},
},
envelope: {
life: 3.4,
make: (a, b) => (ctx, p) => {
const q = arcPoint(a, b, p, 12)
drawEnvelope(ctx, q.x - 3, q.y - 2)
},
},
coin: {
life: 0.7,
make: (a, b) => (ctx, p, f) => {
const q = arcPoint(a, b, p, 14)
drawCoin(ctx, q.x - 2, q.y - 2, f.t)
},
},
stampOk: { life: 1.2, make: (a) => stampDraw(a, true) },
stampNo: { life: 1.2, make: (a) => stampDraw(a, false) },
sparkle: {
life: 1.0,
make: (a) => (ctx, p) => drawSparkle(ctx, Math.round(a.x), Math.round(a.y), p),
},
alert: {
life: 1.5,
// Das Zeichen blinkt (vier Wechsel je Sekunde) und beginnt sichtbar; a = Kopfoberkante des Workers
make: (a) => (ctx, p, f) => {
if (Math.floor(f.t * 4) % 2 === 0) drawAlertMark(ctx, Math.round(a.x), Math.round(a.y))
},
},
drawer: {
life: 0.9,
// Das Blatt verschwindet in der Lade: es endet 3 Einheiten vor dem Ziel
make: (a, b) => {
const end = shortenedEnd(a, b, 3)
return (ctx, p) => {
const q = arcPoint(a, end, p, 6)
drawPaper(ctx, q.x - 3, q.y - 3)
}
},
},
}
/* Startet den benannten Effekt in Weltkoordinaten. from/to = { x, y }; fehlt eines, gilt das andere, fehlen beide, (0, 0).
delay in Sekunden, onEnd genau einmal beim Ablauf. Unbekannter Name oder volle Liste: nichts wird gezeichnet,
onEnd läuft sofort (damit eine Regie, die darauf wartet, nicht hängen bleibt). */
export function spawnFx(fx, name, opts) {
const { from, to, delay = 0, onEnd } = opts || {}
const kind = FX_NAMES.includes(name) ? KINDS[name] : null
if (!kind) {
if (typeof onEnd === 'function') onEnd()
return
}
const a = from || to || { x: 0, y: 0 }
const b = to || a
fx.add(kind.life, kind.make(a, b), onEnd, delay)
}
-134
View File
@@ -1,134 +0,0 @@
// Hochhaus-Layout als Daten: Etagen, Räume und Heimatplätze der Figuren in der Welt 320 × 256 (Übersicht §3).
// Zeichnen (rooms, building) und Regie (director) lesen nur diese Tabelle; ein neuer Raum ist eine Zeile in
// ROOM_META plus eine Rolle. Kein React, kein Appwrite, kein window/document.
import { ROLES, FLOORS, FLOOR_TITLES, ROOM_TITLES } from '../../../lib/workersRoster.js'
export const WORLD = Object.freeze({ w: 320, h: 256 })
export const FLOOR_PITCH = 23 // Etage 22 hoch plus eine Bodenlinie
export const BRIDGE_H = 34 // die Kommandobrücke (Etage 0) ist höher
const TOP0 = 6 // Oberkante der Brücke
const FLOOR_H = 22
const ROOM_X0 = 42 // Räume stehen zwischen x 42 und 228 (Schacht und Etagenschilder liegen daneben)
const ROOM_W = 186
const HOME_DY = 14 // Figurenhöhe: Platz y = Boden - 14
const OTTO_DY = 27 // Otto steht höher (Kommandomütze, 24 breit)
const OTTO_W = 24
const WORKER_W = 12
const CAB_H = 21 // Kabinenhöhe des Aufzugs (lift.js importiert layout.js, nicht umgekehrt)
const meta = (kind, team) => Object.freeze({ kind, team })
// Raum -> Art der Zeichnung und Teamfarbe (Schlüssel aus TEAM in sprites.js)
export const ROOM_META = Object.freeze({
bruecke: meta('bruecke', 'freigabe'),
netz: meta('radar', 'radar'),
kleinanzeigen: meta('inbox', 'radar'),
portale: meta('feed', 'radar'),
foren: meta('board', 'radar'),
aerzte: meta('scout', 'aerzte'),
handwerk: meta('scout', 'handwerk'),
selbst: meta('scout', 'selbst'),
laden: meta('scout', 'laden'),
waage: meta('waage', 'bewertung'),
mail: meta('mail', 'freigabe'),
forum: meta('schreiber', 'freigabe'),
posteingang: meta('sortier', 'freigabe'),
abschluss: meta('vertrag', 'freigabe'),
tisch1: meta('tisch', 'werkstatt'),
tisch2: meta('tisch', 'werkstatt'),
tisch3: meta('tisch', 'werkstatt'),
pruefung: meta('pruefung', 'pruefung'),
pult: meta('pult', 'freigabe'),
tresor: meta('tresor', 'tresor'),
archiv: meta('archiv', 'archiv'),
})
// Räume ohne Worker: stehen in ihrer Etage hinter den Räumen der Rollen
const WORKERLESS = Object.freeze({ freigabe: ['pult'], tresor: ['tresor'] })
// Etage i: Brücke oben, danach im Raster 23 (Oberkante 41, 64, 87, ...); floorY ist die Bodenlinie
function floorBox(i) {
const h = i === 0 ? BRIDGE_H : FLOOR_H
const top = i === 0 ? TOP0 : TOP0 + BRIDGE_H + 1 + FLOOR_PITCH * (i - 1)
return { top, floorY: top + h, h }
}
// n Räume teilen die Breite gleichmäßig; der letzte nimmt den Rest (4 Räume: 46, 46, 46, 48)
function splitRooms(n) {
const w = Math.floor(ROOM_W / n)
return Array.from({ length: n }, (_, i) => ({ x: ROOM_X0 + i * w, w: i === n - 1 ? ROOM_W - w * (n - 1) : w }))
}
// Orte für Effekte: Briefkasten am Pult (Spitze), Tresor (Mitte der Tür), Schublade im Archiv (erster Aktenschrank, rechts neben
// Arne; rooms.js zeichnet die Schränke ab Raum + 100) und ein Punkt rechts außerhalb des Turms. Der Test in workersRooms.test.mjs
// prüft, dass die Zeichnung jeden dieser Orte trifft. Fehlt die Etage im Layout (nur bei eigenen Etagenlisten), ist der Ort null.
function buildSpots(floors) {
const at = (floorId, dx, dy) => {
const f = floors.find((fl) => fl.id === floorId)
return f ? { x: ROOM_X0 + dx, y: f.floorY + dy } : null
}
const tresor = at('tresor', 121, -11)
return {
pult: at('freigabe', 93, -15),
tresor,
aussen: tresor && { x: WORLD.w + 4, y: tresor.y - 1 },
archiv: at('keller', 106, -16),
}
}
// Layout aus Rollen und Etagenliste (Standard: das Rollen-Register). Wirft bei unbekanntem Raum oder unbekannter Etage.
export function buildLayout(roles = ROLES, floors = FLOORS) {
for (const r of roles) {
if (!Object.hasOwn(ROOM_META, r.room)) throw new Error(`Unbekannter Raum: ${r.room}`)
if (!floors.includes(r.floor)) throw new Error(`Unbekannte Etage: ${r.floor}`)
}
const homes = {}
const list = floors.map((id, index) => {
const box = floorBox(index)
const entries = roles.filter((r) => r.floor === id).map((r) => ({ id: r.room, workerId: r.workerId }))
for (const room of WORKERLESS[id] ?? []) entries.push({ id: room, workerId: null })
const slots = splitRooms(entries.length)
const rooms = entries.map((e, k) => {
const { kind, team } = ROOM_META[e.id]
return { id: e.id, kind, title: ROOM_TITLES[e.id], team, x: slots[k].x, w: slots[k].w, workerId: e.workerId }
})
for (const room of rooms) {
if (!room.workerId) continue
const otto = room.workerId === 'otto'
const x = room.x + Math.floor((room.w - (otto ? OTTO_W : WORKER_W)) / 2)
homes[room.workerId] = { x, y: box.floorY - (otto ? OTTO_DY : HOME_DY), floor: index, room: room.id }
}
return { id, index, title: FLOOR_TITLES[id] ?? id, top: box.top, floorY: box.floorY, h: box.h, rooms }
})
return { floors: list, homes, spots: buildSpots(list) }
}
// Etagenindex zur Kennung; unbekannt -> -1
export function floorIndex(layout, id) {
return layout.floors.findIndex((f) => f.id === id)
}
// Raum zur Kennung; unbekannt -> null
export function roomById(layout, roomId) {
for (const f of layout.floors) {
const room = f.rooms.find((r) => r.id === roomId)
if (room) return room
}
return null
}
// Heimatplatz des Workers; unbekannte Kennung (auch Prototyp-Namen) -> null
export function homeOf(layout, workerId) {
return typeof workerId === 'string' && Object.hasOwn(layout.homes, workerId) ? layout.homes[workerId] : null
}
// Standhöhe einer Figur auf Etage i (obere Kante des 14 hohen Körpers)
export function fy(layout, i) {
return layout.floors[i].floorY - HOME_DY
}
// Oberkante der Aufzugkabine, wenn sie auf Etage i hält
export function cabTop(layout, i) {
return layout.floors[i].floorY - CAB_H
}
-169
View File
@@ -1,169 +0,0 @@
// Aufzug des Hustler-Hochhauses: Warteschlange, Zustandsautomat (Anfahren, Einsteigen, Fahren, Aussteigen) und Kabine.
// Portiert aus entwuerfe/scene-turm.js (Kabine Z. 316-330, Zustandsautomat Z. 466-492). Neu: cancel/reset geben die Kabine
// frei (die Regie bricht Gruppen ab, ohne dass der Aufzug hängen bleibt), ungültige Anfragen kommen abgebrochen zurück.
// Die Etagenlampen zeichnet building.js über at(i). Kein React, kein Appwrite, kein window/document.
import { cabTop } from './layout.js'
import { px, tint, PAL } from './sprites.js'
// Maße in Weltkoordinaten: cabWx = linke Kante des Fahrgasts in der Kabine (12 breiter Körper mittig in der 20 breiten Kabine),
// exitX = Ziel des ersten Schritts nach dem Aussteigen, slot0/slotStep = Wartepunkte vor dem Schacht (wer zuerst kommt, steht links),
// speed = Einheiten je Sekunde, cabH = Kabinenhöhe (gleich der in layout.js)
export const LIFT = Object.freeze({ cabWx: 22, exitX: 50, slot0: 52, slotStep: 14, speed: 110, cabH: 21 })
const CAB_X = 18 // linke Kante der Kabine (20 breit)
const CAB_W = 20
const ROPE_X = 28
const ROPE_TOP = 9 // das Seil hängt unter der Seilrolle am Schachtkopf
const RIDER_DY = 7 // Fahrgast: obere Kante des Körpers 7 unter der Kabinenoberkante (= Standhöhe auf der Etage)
const CLOSE_S = 0.3 // Türen schließen, bevor die Fahrt beginnt
const NEAR = 1.5 // at(i): Kabine gilt als auf der Etage, wenn sie höchstens so weit davon entfernt ist
const START_FLOOR = 2
const FRAME = PAL.metal2
const CAB_BACK = '#f1f3f8'
const CAB_RAIL = '#cfd5df'
const CAB_BASE = '#e1e5ec'
const CAB_LAMP = '#ffe9a8'
/* createLift(layout) → { y, state, request(agent, from, to), update(dt), cancel(agent), reset(), at(floorIndex), drawBack(ctx), drawFront(ctx) }
y = Oberkante der Kabine, state = idle | toPick | boarding | closing | riding | unloading.
request legt eine Anfrage { a, from, to, st } hinten an (FIFO); st läuft queued -> open -> boarded -> arrived -> out, außerdem cancelled.
boarded und out setzt der Fahrgast (die Regie), alle anderen Übergänge der Aufzug. Während der Fahrt (closing, riding) folgt der Agent
der Kabine: a.y = y + 7, a.x = cabWx, a.frame = 0.
Eine Anfrage mit ungültiger Etage (kein ganzer Index im Layout) oder ohne Agenten-Objekt kommt sofort als cancelled zurück und wird nie bedient. */
export function createLift(layout) {
const floorCount = layout.floors.length
const isFloor = (i) => Number.isInteger(i) && i >= 0 && i < floorCount
const lift = { y: cabTop(layout, Math.min(START_FLOOR, floorCount - 1)), state: 'idle' }
let queue = [] // wartende Anfragen, vorne die nächste
let req = null // laufende Anfrage
let closeT = 0
// Kabine frei: keine laufende Anfrage, Leerlauf (die Kabine bleibt, wo sie ist)
function release() {
req = null
lift.state = 'idle'
closeT = 0
}
// Fährt um höchstens speed * dt auf ty zu; true = angekommen
function cabMove(ty, dt) {
const d = ty - lift.y
const s = LIFT.speed * dt
if (Math.abs(d) <= s) {
lift.y = ty
return true
}
lift.y += d > 0 ? s : -s
return false
}
// Der Fahrgast steht in der Kabine
function follow() {
if (!req) return
req.a.y = lift.y + RIDER_DY
req.a.x = LIFT.cabWx
req.a.frame = 0
}
lift.request = function request(a, from, to) {
const r = { a, from, to, st: 'queued' }
if (!a || typeof a !== 'object' || !isFloor(from) || !isFloor(to)) {
r.st = 'cancelled'
return r
}
queue.push(r)
return r
}
lift.update = function update(dt) {
if (!Number.isFinite(dt) || dt <= 0) return
// von außen abgebrochene laufende Anfrage: Kabine frei, kein Festhängen in boarding
if (req && req.st === 'cancelled') release()
if (lift.state === 'idle') {
while (queue.length && queue[0].st === 'cancelled') queue.shift()
if (queue.length) {
req = queue.shift()
lift.state = 'toPick'
}
}
if (lift.state === 'toPick') {
if (cabMove(cabTop(layout, req.from), dt)) {
req.st = 'open'
lift.state = 'boarding'
}
} else if (lift.state === 'boarding') {
if (req.st === 'boarded') {
lift.state = 'closing'
closeT = 0
}
} else if (lift.state === 'closing') {
closeT += dt
follow()
if (closeT > CLOSE_S) lift.state = 'riding'
} else if (lift.state === 'riding') {
// erst fahren, dann mitnehmen: der Fahrgast hinkt der Kabine nicht um einen Schritt hinterher (die Vorlage tut es)
const arrived = cabMove(cabTop(layout, req.to), dt)
follow()
if (arrived) {
req.st = 'arrived'
lift.state = 'unloading'
}
} else if (lift.state === 'unloading') {
if (req.st === 'out') release()
}
}
// Alle Anfragen des Agenten abbrechen; steckt er in der Kabine, ist sie danach frei (der Agent wird nicht mehr bewegt).
// Ein bereits ausgestiegener Fahrgast (out) bleibt out.
lift.cancel = function cancel(agent) {
const keep = []
for (const r of queue) {
if (r.a === agent) r.st = 'cancelled'
else keep.push(r)
}
queue = keep
if (req && req.a === agent) {
if (req.st !== 'out') req.st = 'cancelled'
release()
}
}
// Alle wartenden und die laufende Anfrage abbrechen (Schnappschuss, Neuaufbau der Szene); die Kabine bleibt stehen
lift.reset = function reset() {
for (const r of queue) r.st = 'cancelled'
queue = []
if (req && req.st !== 'out') req.st = 'cancelled'
release()
}
// Steht die Kabine auf Etage i? (Etagenlampe grün); ungültiger Index -> false
lift.at = function at(i) {
return isFloor(i) && Math.abs(lift.y - cabTop(layout, i)) < NEAR
}
// Seil und Rückwand: vor den Figuren zeichnen
lift.drawBack = function drawBack(ctx) {
const cy = Math.round(lift.y)
px(ctx, ROPE_X, ROPE_TOP, 1, cy - ROPE_TOP, FRAME)
px(ctx, CAB_X, cy, CAB_W, LIFT.cabH, CAB_BACK)
px(ctx, CAB_X + 1, cy + 11, CAB_W - 2, 1, CAB_RAIL)
px(ctx, CAB_X + 1, cy + 18, CAB_W - 2, 2, CAB_BASE)
}
// Glasschleier, Rahmen, Lampe und Aufhängung: nach den Figuren zeichnen
lift.drawFront = function drawFront(ctx) {
const cy = Math.round(lift.y)
tint(ctx, CAB_X + 1, cy + 1, CAB_W - 2, LIFT.cabH - 2, '#ffffff', 0.18)
px(ctx, CAB_X, cy, CAB_W, 1, FRAME)
px(ctx, CAB_X, cy + LIFT.cabH - 1, CAB_W, 1, FRAME)
px(ctx, CAB_X, cy, 1, LIFT.cabH, FRAME)
px(ctx, CAB_X + CAB_W - 1, cy, 1, LIFT.cabH, FRAME)
px(ctx, 24, cy + 1, 8, 1, CAB_LAMP)
px(ctx, 26, cy - 2, 4, 2, FRAME)
}
return lift
}
-255
View File
@@ -1,255 +0,0 @@
// Möbel, Requisiten und Branchen-Icons des Hustler-Hochhauses.
// Portiert aus entwuerfe/sprites.js (Möbel und Requisiten, ohne drawConveyor und drawZone). Neu: drawTray, drawSign,
// drawIcon samt ICONS und der Schalter `on` an drawMonitor.
// Alle Maße in logischen Einheiten; gezeichnet wird über px() aus sprites.js, es gibt keinen eigenen Zustand.
// Kein React, kein Appwrite, kein window/document.
import { px, label, PAL, TEAM, HAT } from './sprites.js'
// ---------- Möbel ----------
export function drawDesk(ctx, x, y, w, accent) {
px(ctx, x, y, w, 1, PAL.wood2)
px(ctx, x, y + 1, w, 4, PAL.wood)
px(ctx, x, y + 5, w, 1, PAL.wood2)
if (accent) px(ctx, x, y + 5, w, 1, accent)
px(ctx, x + 1, y + 6, 1, 5, PAL.wood2)
px(ctx, x + w - 2, y + 6, 1, 5, PAL.wood2)
px(ctx, x + 1, y + 11, w - 2, 1, PAL.shadow)
}
// on = false: nur Rahmen, Schirm und Fuß, keine Zeilen und kein Blinken
export function drawMonitor(ctx, x, y, t, color, on = true) {
const c = color || '#7fb2ff'
px(ctx, x, y, 10, 7, PAL.o)
px(ctx, x + 1, y + 1, 8, 5, PAL.screen)
if (on) {
const long = Math.floor((t || 0) * 2) % 2 === 0
px(ctx, x + 2, y + 2, 5, 1, c)
px(ctx, x + 2, y + 4, long ? 4 : 2, 1, c)
}
px(ctx, x + 4, y + 7, 2, 2, PAL.o)
px(ctx, x + 2, y + 9, 6, 1, PAL.o)
}
export function drawCabinet(ctx, x, y, drawers) {
const n = drawers || 3
px(ctx, x, y, 12, n * 6 + 1, PAL.metal2)
for (let i = 0; i < n; i++) {
px(ctx, x + 1, y + 1 + i * 6, 10, 5, PAL.metal)
px(ctx, x + 4, y + 3 + i * 6, 4, 1, PAL.metal2)
}
px(ctx, x, y + n * 6 + 1, 12, 1, PAL.shadow)
}
export function drawPlant(ctx, x, y) {
px(ctx, x + 2, y, 2, 2, '#5cbf8a')
px(ctx, x, y + 2, 2, 2, '#5cbf8a')
px(ctx, x + 4, y + 1, 2, 3, '#3a9466')
px(ctx, x + 2, y + 2, 2, 3, '#3a9466')
px(ctx, x + 1, y + 5, 4, 4, '#c9714f')
px(ctx, x + 1, y + 5, 4, 1, '#a85a3c')
}
// Ablagekorb / Eingangskorb, 10 × 4: voll = Papierstapel ragt über den Rand
export function drawTray(ctx, x, y, filled) {
px(ctx, x, y + 1, 10, 1, PAL.metal2) // Rand
px(ctx, x, y + 2, 10, 1, PAL.metal) // Vorderwand
px(ctx, x, y + 3, 10, 1, PAL.metal2) // Boden
if (filled) {
px(ctx, x + 1, y, 8, 1, PAL.g) // Oberkante des Stapels
px(ctx, x + 1, y + 1, 8, 1, PAL.w) // oberstes Blatt in der Öffnung
px(ctx, x + 3, y + 1, 4, 1, PAL.p) // eine Textzeile darauf
}
}
// ---------- Requisiten ----------
export function drawCoin(ctx, x, y, t) {
const f = Math.floor((t || 0) * 6) % 4
const w = [5, 3, 1, 3][f]
const ox = (5 - w) / 2
px(ctx, x + ox, y, w, 5, PAL.gold2)
if (w > 2) px(ctx, x + ox + 1, y + 1, w - 2, 3, PAL.gold)
}
export function drawTrophy(ctx, x, y) {
px(ctx, x, y, 7, 1, PAL.gold2)
px(ctx, x, y + 1, 7, 3, PAL.gold)
px(ctx, x + 1, y + 4, 5, 1, PAL.gold2)
px(ctx, x + 3, y + 5, 1, 2, PAL.gold2)
px(ctx, x + 1, y + 7, 5, 2, PAL.wood2)
px(ctx, x + 1, y + 1, 1, 2, '#fff3c4')
}
export function drawStamp(ctx, x, y, ok) {
const c = ok ? PAL.green : PAL.red
px(ctx, x, y, 7, 1, c)
px(ctx, x, y + 4, 7, 1, c)
px(ctx, x, y, 1, 5, c)
px(ctx, x + 6, y, 1, 5, c)
if (ok) {
// Haken
px(ctx, x + 2, y + 2, 1, 1, c)
px(ctx, x + 3, y + 3, 1, 1, c)
px(ctx, x + 4, y + 1, 1, 2, c)
} else {
// Kreuz
px(ctx, x + 2, y + 1, 1, 1, c)
px(ctx, x + 4, y + 1, 1, 1, c)
px(ctx, x + 3, y + 2, 1, 1, c)
px(ctx, x + 2, y + 3, 1, 1, c)
px(ctx, x + 4, y + 3, 1, 1, c)
}
}
export function drawRadar(ctx, x, y, t) {
px(ctx, x + 2, y, 10, 1, PAL.o)
px(ctx, x, y + 1, 14, 10, PAL.o)
px(ctx, x + 1, y + 2, 12, 8, '#e8f2fc')
px(ctx, x + 6, y + 5, 2, 2, TEAM.radar)
const a = (t || 0) * 2.2
for (let i = 1; i < 6; i++) px(ctx, x + 7 + Math.round(Math.cos(a) * i), y + 6 + Math.round(Math.sin(a) * i * 0.7), 1, 1, TEAM.radar)
const blink = Math.floor((t || 0) * 3) % 3
if (blink === 0) px(ctx, x + 3, y + 3, 1, 1, PAL.red)
if (blink === 1) px(ctx, x + 10, y + 7, 1, 1, PAL.red)
px(ctx, x + 6, y + 11, 2, 3, PAL.o)
px(ctx, x + 3, y + 14, 8, 1, PAL.o)
}
// Briefkasten des Pults; das rote Abzeichen mit „!“ steht nur bei count > 0
export function drawMailbox(ctx, x, y, count) {
px(ctx, x, y + 2, 12, 8, TEAM.freigabe)
px(ctx, x + 1, y, 10, 2, TEAM.freigabe)
px(ctx, x + 2, y + 4, 8, 2, HAT.overseer[1])
px(ctx, x + 5, y + 10, 2, 5, PAL.o)
if (count > 0) {
px(ctx, x + 9, y - 3, 5, 5, PAL.red)
px(ctx, x + 11, y - 2, 1, 3, PAL.w)
}
}
export function drawVault(ctx, x, y, t) {
px(ctx, x, y, 22, 20, PAL.metal2)
px(ctx, x + 1, y + 1, 20, 18, PAL.metal)
px(ctx, x + 7, y + 6, 8, 8, PAL.metal2)
px(ctx, x + 8, y + 7, 6, 6, PAL.metal)
const a = (t || 0) * 0.8
px(ctx, x + 11 + Math.round(Math.cos(a) * 2), y + 10 + Math.round(Math.sin(a) * 2), 1, 1, PAL.o)
px(ctx, x + 18, y + 9, 2, 3, PAL.metal2)
px(ctx, x, y + 20, 22, 1, PAL.shadow)
}
export function drawScale(ctx, x, y, tilt) {
const d = Math.round(tilt || 0)
px(ctx, x + 7, y + 2, 2, 12, PAL.o)
px(ctx, x + 3, y + 14, 10, 2, PAL.o)
px(ctx, x, y + 2 - d, 16, 1, PAL.o)
px(ctx, x - 1, y + 3 - d, 5, 2, TEAM.bewertung)
px(ctx, x + 12, y + 3 + d, 5, 2, TEAM.bewertung)
}
// Weißes Schild mit dunklem Rand, 8 hoch; (x, y) = linke obere Ecke, Text zentriert (z. B. „noch nicht angeschlossen“)
export function drawSign(ctx, x, y, w, text) {
px(ctx, x, y, w, 8, PAL.o)
px(ctx, x + 1, y + 1, w - 2, 6, PAL.w)
label(ctx, text, x + w / 2, y + 2.5, { size: 9, color: PAL.ink, align: 'center' })
}
// ---------- Branchen-Icons (7 × 7) ----------
// '.' leer, 'x' Linie in der Teamfarbe, 'a' Akzent in Gold. Schlüssel = die acht Such-Räume (SEARCH_ROOMS)
const icon = (...rows) => Object.freeze(rows)
export const ICONS = Object.freeze({
netz: icon( // Funkwellen
'.xxxxx.',
'x.....x',
'..xxx..',
'.x...x.',
'.......',
'...a...',
'...a...',
),
kleinanzeigen: icon( // Umschlag mit @-Punkt
'xxxxxxx',
'xx...xx',
'x.x.x.x',
'x..x..x',
'x.....x',
'x..a..x',
'xxxxxxx',
),
portale: icon( // Zeilenliste
'xxxxxxx',
'.......',
'a.xxxx.',
'.......',
'a.xxxx.',
'.......',
'a.xxxx.',
),
foren: icon( // Sprechblase
'.xxxxx.',
'x.....x',
'x.a.a.x',
'x.....x',
'.xxxxx.',
'..xx...',
'.x.....',
),
aerzte: icon( // Kreuz
'..xxx..',
'..xxx..',
'xxxxxxx',
'xxxxxxx',
'xxxxxxx',
'..xxx..',
'..xxx..',
),
handwerk: icon( // Hammer
'.xxxxx.',
'xxxxxxx',
'.xxxxx.',
'...a...',
'...a...',
'...a...',
'...a...',
),
selbst: icon( // Person
'..xxx..',
'.xxxxx.',
'..xxx..',
'.......',
'.xxxxx.',
'xxxxxxx',
'xxxxxxx',
),
laden: icon( // Markise
'.xxxxx.',
'xaxaxax',
'xaxaxax',
'x.x.x.x',
'.......',
'x.....x',
'xxxxxxx',
),
})
// Zeichnet das Icon `name` mit der linken oberen Ecke bei (x, y): 'x' in `color`, 'a' in Gold.
// Gleiche Zeichen einer Zeile werden zu einem Rechteck zusammengefasst; ein unbekannter Name zeichnet nichts.
export function drawIcon(ctx, name, x, y, color) {
const rows = typeof name === 'string' ? ICONS[name] : null
if (!Array.isArray(rows)) return
for (let r = 0; r < rows.length; r++) {
const row = rows[r]
let c = 0
while (c < row.length) {
const ch = row[c]
let end = c + 1
while (end < row.length && row[end] === ch) end++
if (ch === 'x') px(ctx, x + c, y + r, end - c, 1, color)
else if (ch === 'a') px(ctx, x + c, y + r, end - c, 1, PAL.gold)
c = end
}
}
}
-329
View File
@@ -1,329 +0,0 @@
// Räume des Hustler-Hochhauses: eine Zeichnung je Raumart (kind), Pokal-Seiten, graue Räume und die Anzeigewerte `look`.
// Portiert aus entwuerfe/scene-turm.js (drawBridge ... drawArchive, Z. 182-314) auf die Raumtabelle des Layouts: alle Maße
// relativ zu room.x und zum Boden der Etage, Schriften nur über label(). Rückwand und Boden zeichnet building.js davor.
// Kein React, kein Appwrite, kein window/document.
import { px, tint, label, drawPaper, PAL, TEAM } from './sprites.js'
import { drawDesk, drawMonitor, drawCabinet, drawPlant, drawTray, drawTrophy, drawStamp, drawRadar, drawMailbox, drawVault, drawScale, drawSign, drawIcon } from './props.js'
const TROPHIES_PER_PAGE = 8
const TINT_ALPHA = 0.10 // Teamfarbton über jedem Raum
const GREY_ALPHA = 0.45
const GREY_TINT = '#9aa3b2'
const DIVIDER = '#e1e5ec'
const SIGN_TEXT = 'noch nicht angeschlossen'
const SIGN_MAX_W = 60
const TITLE_HALO = 'rgba(255, 255, 255, 0.85)' // Rand der Raumtitel
const STEP_COLORS = [TEAM.radar, TEAM.bewertung, TEAM.werkstatt, TEAM.pruefung, TEAM.freigabe] // Stationen der Pipeline-Tafel
const FEED_ROWS = [8, 5, 9] // Breiten der durchlaufenden Zeilen im Feed-Schirm
const MAX_PAGE_DOTS = 19
// Anzeigewerte der Räume. Die Regie (director.js) schreibt sie, die Zeichnung liest sie nur.
// greyRooms: nicht angeschlossene Räume, busyRooms: Räume, deren Worker gerade arbeitet (dort laufen die Monitore).
export function createLook() {
return {
greyRooms: new Set(),
busyRooms: new Set(),
pultOpen: 0,
dealsMonth: 0,
botLeads: 0,
trophyPage: 0, // gezeigte Pokal-Seite
pageT: 0, // Zeit seit der letzten Blätterung (Sekunden)
trophyPop: 0, // Restzeit des Aufblinkens des neuesten Pokals
vaultFlash: 0, // Restzeit des Tresor-Blitzes
paused: false,
}
}
// Pokal-Seiten: perPage je Seite, die Seite wird auf 0..pages-1 geklemmt; ungültige Werte zählen als 0
export function trophyPaging(total, page, perPage = TROPHIES_PER_PAGE) {
const size = Number.isFinite(perPage) && perPage >= 1 ? Math.floor(perPage) : TROPHIES_PER_PAGE
const n = whole(total)
const pages = Math.max(1, Math.ceil(n / size))
const p = Number.isFinite(page) ? Math.min(pages - 1, Math.max(0, Math.floor(page))) : 0
const from = p * size
return { shown: Math.min(size, n - from), page: p, pages, from }
}
// Zähler als ganze Zahl >= 0
function whole(value) {
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0
}
// ---------- Hilfen für die Raumzeichnungen ----------
// r = { room, x, w, top, g, tp, busy, grey, color, look }: g = Boden der Etage, tp = Zeit mit Versatz je Raum (Monitore blinken nicht im Gleichschritt)
const deskAt = (ctx, r, dx, w) => drawDesk(ctx, r.x + dx, r.g - 11, w, r.color)
const monitorAt = (ctx, r, dx, color = r.color) => drawMonitor(ctx, r.x + dx, r.g - 21, r.tp, color, r.busy)
const hop = (r) => (r.busy && Math.floor(r.tp * 2) % 2 === 0 ? 3 : 0) // Stempel springt im Takt, solange gearbeitet wird
// Monitor mit Schreib-Animation: erste Zeile wächst, dann die zweite, dazu eine blinkende Schreibmarke
function drawTypingMonitor(ctx, r, dx) {
const x = r.x + dx
const y = r.g - 21
drawMonitor(ctx, x, y, r.tp, r.color, false)
if (!r.busy) return
const n = Math.floor(r.tp * 4) % 9
const first = Math.min(6, n + 1)
const second = Math.max(0, n - 5)
px(ctx, x + 2, y + 2, first, 1, r.color)
if (second > 0) px(ctx, x + 2, y + 4, second, 1, r.color)
if (n % 2 === 0) px(ctx, x + 2 + (second > 0 ? second : first), y + (second > 0 ? 4 : 2), 1, 1, PAL.w)
}
// Pappkarton 15 × 8 mit Etikett
function drawBox(ctx, x, y) {
px(ctx, x, y, 15, 8, PAL.wood)
px(ctx, x, y, 15, 1, PAL.wood2)
px(ctx, x + 5, y + 3, 5, 2, '#efe3d2')
}
// ---------- Raumarten ----------
export const ROOM_DRAWERS = Object.freeze({
// Kommandobrücke: Konsole mit 2 Monitoren, Pipeline-Tafel, Fenster in die Nacht, Pflanze
bruecke(ctx, r) {
const { x, top, g, tp } = r
px(ctx, x + 62, top + 3, 104, 22, PAL.metal2)
px(ctx, x + 63, top + 4, 102, 20, '#e8ebf9')
px(ctx, x + 63, top + 16, 102, 8, '#dfe3f5')
for (const [dx, dy, w, h] of [[66, 18, 6, 6], [74, 14, 5, 10], [82, 17, 8, 7], [128, 15, 6, 9], [138, 18, 9, 6], [152, 13, 5, 11]]) px(ctx, x + dx, top + dy, w, h, '#cdd3ee')
const stars = [[70, 6], [86, 9], [99, 5], [130, 7], [144, 5], [156, 10], [108, 8], [78, 16]]
stars.forEach(([dx, dy], i) => {
if ((Math.floor(tp * 1.3) + i) % 3 !== 0) px(ctx, x + dx, top + dy, 1, 1, '#b3bbec')
})
px(ctx, x + 150, top + 7, 5, 5, '#f6dc7a')
px(ctx, x + 152, top + 6, 4, 4, '#e8ebf9')
px(ctx, x + 113, top + 4, 2, 20, PAL.metal2)
px(ctx, x + 60, top + 25, 108, 2, '#aab2c0')
// Konsole
drawDesk(ctx, x + 6, g - 11, 30, r.color)
drawMonitor(ctx, x + 10, g - 21, tp, '#b7a4f0', r.busy)
drawMonitor(ctx, x + 24, g - 21, tp + 1, '#7fb2ff', r.busy)
// Pipeline-Tafel
px(ctx, x + 42, top + 5, 18, 14, PAL.metal2)
px(ctx, x + 43, top + 6, 16, 12, PAL.w)
px(ctx, x + 45, top + 10, 12, 1, PAL.metal)
STEP_COLORS.forEach((color, i) => px(ctx, x + 44 + 3 * i, top + 9, 2, 2, color))
px(ctx, x + 45, top + 13, 3, 3, '#ffe28a')
px(ctx, x + 50, top + 13, 3, 3, '#bde5c9')
px(ctx, x + 55, top + 13, 3, 3, '#f6b9b9')
drawPlant(ctx, x + 174, g - 9)
},
// Netz-Radar: Radar auf dem Boden, Tisch daneben
radar(ctx, r) {
deskAt(ctx, r, 2, 14)
drawRadar(ctx, r.x + 31, r.g - 15, r.busy ? r.tp : 0)
},
// Kleinanzeigen: Tisch mit Monitor, Eingangskorb auf dem Wandbrett (voll, solange gearbeitet wird)
inbox(ctx, r) {
deskAt(ctx, r, 2, 14)
monitorAt(ctx, r, 4)
px(ctx, r.x + 31, r.g - 11, 12, 1, PAL.wood2)
drawTray(ctx, r.x + 32, r.g - 15, r.busy)
},
// Portale: Tisch mit Monitor, Wandschirm mit durchlaufenden Zeilen
feed(ctx, r) {
deskAt(ctx, r, 2, 14)
monitorAt(ctx, r, 4)
const sx = r.x + 31
const sy = r.g - 18
px(ctx, sx, sy, 14, 10, PAL.o)
px(ctx, sx + 1, sy + 1, 12, 8, PAL.screen)
if (!r.busy) return
const shift = Math.floor(r.tp * 3) % FEED_ROWS.length
for (let i = 0; i < FEED_ROWS.length; i++) px(ctx, sx + 2, sy + 2 + i * 2, FEED_ROWS[(i + shift) % FEED_ROWS.length], 1, r.color)
},
// Foren: Tisch mit Monitor, Tafel mit zwei Sprechblasen
board(ctx, r) {
deskAt(ctx, r, 2, 14)
monitorAt(ctx, r, 4)
const bx = r.x + 31
const by = r.g - 19
px(ctx, bx, by, 14, 11, PAL.wood2)
px(ctx, bx + 1, by + 1, 12, 9, '#eadfcf')
px(ctx, bx + 2, by + 2, 6, 3, PAL.w)
px(ctx, bx + 3, by + 5, 1, 1, PAL.w)
px(ctx, bx + 6, by + 6, 6, 3, r.color)
px(ctx, bx + 10, by + 9, 1, 1, r.color)
},
// Branchen-Scout: Tisch in der Teamfarbe, Monitor, Branchen-Icon an der Wand (Icon = Raumkennung)
scout(ctx, r) {
deskAt(ctx, r, 2, 14)
monitorAt(ctx, r, 4)
drawIcon(ctx, r.room.id, r.x + r.w - 10, r.top + 8, r.color)
},
// Waage: Waage links der Figur (schwankt beim Bewerten), Tisch mit Papieren rechts
waage(ctx, r) {
drawScale(ctx, r.x + 62, r.g - 16, r.busy ? Math.round(Math.sin(r.tp * 6) * 2.4) : 0)
deskAt(ctx, r, 104, 22)
drawPaper(ctx, r.x + 109, r.g - 18)
drawPaper(ctx, r.x + 117, r.g - 18)
},
// Mail-Stube: Tisch, Monitor, Briefstapel auf einem Beistelltisch
mail(ctx, r) {
deskAt(ctx, r, 2, 14)
monitorAt(ctx, r, 4)
px(ctx, r.x + 31, r.g - 7, 12, 1, PAL.wood2)
px(ctx, r.x + 32, r.g - 6, 1, 6, PAL.wood2)
px(ctx, r.x + 41, r.g - 6, 1, 6, PAL.wood2)
for (let i = 0; i < 3; i++) {
px(ctx, r.x + 32, r.g - 9 - i * 2, 10, 2, PAL.w)
px(ctx, r.x + 32, r.g - 9 - i * 2, 10, 1, PAL.g)
}
},
// Forum-Stube: Tisch, Monitor mit Schreib-Animation
schreiber(ctx, r) {
deskAt(ctx, r, 2, 14)
drawTypingMonitor(ctx, r, 4)
},
// Posteingang: Tisch, Regal mit drei Ablagekörben (Eingang voll, Mitte beim Sortieren, Ausgang leer)
sortier(ctx, r) {
deskAt(ctx, r, 2, 14)
const rx = r.x + 31
px(ctx, rx, r.g - 20, 1, 18, PAL.wood2)
px(ctx, rx + 11, r.g - 20, 1, 18, PAL.wood2)
const filled = [true, r.busy, false]
filled.forEach((full, i) => drawTray(ctx, rx + 1, r.g - 19 + i * 6, full))
},
// Abschluss: Tisch, Vertrag, Stempel (springt beim Arbeiten)
vertrag(ctx, r) {
deskAt(ctx, r, 2, 14)
drawPaper(ctx, r.x + 3, r.g - 18)
drawStamp(ctx, r.x + 10, r.g - 16 - hop(r), true)
},
// Werkbank: Tisch, Monitor
tisch(ctx, r) {
deskAt(ctx, r, 6, 18)
monitorAt(ctx, r, 10)
},
// Prüftisch: Tisch mit zwei Stempeln, Handy auf einem Sockel (zeigt beim Prüfen wechselnde Bilder)
pruefung(ctx, r) {
deskAt(ctx, r, 60, 22)
drawStamp(ctx, r.x + 66, r.g - 16, false)
drawStamp(ctx, r.x + 73, r.g - 16 - hop(r), true)
const sx = r.x + 108
px(ctx, sx, r.g - 5, 10, 5, PAL.wood)
px(ctx, sx, r.g - 5, 10, 1, PAL.wood2)
px(ctx, sx + 2, r.g - 15, 6, 10, PAL.o)
const shot = Math.floor(r.tp * 2) % 2 === 0 ? '#cfe3ff' : '#bde5c9'
px(ctx, sx + 3, r.g - 14, 4, 7, r.busy ? shot : PAL.screen)
px(ctx, sx + 4, r.g - 7, 2, 1, PAL.mute)
},
// Dein Pult: der Briefkasten in der Mitte; Zahl der offenen Einträge daneben (keine bei 0)
pult(ctx, r) {
const open = whole(r.look.pultOpen)
const cx = r.x + r.w / 2
drawMailbox(ctx, cx - 6, r.g - 15, open)
if (open > 0) label(ctx, open > 99 ? '99+' : String(open), cx + 15, r.g - 19, { size: 11, color: PAL.w, weight: 700, align: 'center', bg: PAL.red })
},
// Tresor: Pokalbrett (eine Seite zu 8), Tresortür, Zähler der Deals dieses Monats
tresor(ctx, r) {
const { look } = r
const total = whole(look.dealsMonth)
const pg = trophyPaging(total, look.trophyPage)
const shelf = r.g - 9
px(ctx, r.x + 24, shelf, 78, 2, PAL.wood2)
px(ctx, r.x + 26, shelf + 2, 2, 3, PAL.wood2)
px(ctx, r.x + 98, shelf + 2, 2, 3, PAL.wood2)
for (let i = 0; i < pg.shown; i++) {
const newest = pg.from + i === total - 1
if (newest && look.trophyPop > 0 && Math.floor(look.trophyPop * 10) % 2) continue
drawTrophy(ctx, r.x + 26 + 9 * i, shelf - 9)
}
// Seitenpunkte, wenn mehr als eine Seite da ist
for (let p = 0; p < Math.min(pg.pages, MAX_PAGE_DOTS); p++) px(ctx, r.x + 24 + p * 4, shelf + 6, 2, 2, p === pg.page ? TEAM.tresor : PAL.metal)
drawVault(ctx, r.x + 110, r.g - 21, r.tp)
if (look.vaultFlash > 0) tint(ctx, r.x + 110, r.g - 21, 22, 20, PAL.gold, 0.3 * Math.min(1, look.vaultFlash * 2))
label(ctx, `${total} Deals`, r.x + 138, r.g - 15, { size: 11, color: PAL.ink, weight: 600 })
},
// Archiv & Register: Kartons links, vier Aktenschränke rechts der Figur (beim Arbeiten springt je eine Schublade auf), Zähler der Bot-Leads.
// Der erste Schrank (x + 100 ... + 112) trägt den Effektort layout.spots.archiv (Mitte, oberste Schublade): dort verschwindet das Blatt.
archiv(ctx, r) {
drawBox(ctx, r.x + 14, r.g - 9)
drawBox(ctx, r.x + 31, r.g - 9)
drawBox(ctx, r.x + 22, r.g - 17)
const open = Math.floor(r.tp * 1.5) % 4
for (let i = 0; i < 4; i++) {
const x = r.x + 100 + 14 * i
const y = r.g - 20
drawCabinet(ctx, x, y, 3)
if (r.busy && i === open) {
px(ctx, x + 1, y + 7, 10, 5, '#fafbfd')
px(ctx, x + 2, y + 9, 8, 2, PAL.p)
px(ctx, x + 3, y + 6, 6, 1, PAL.w)
}
}
label(ctx, `${whole(r.look.botLeads)} Bot-Leads`, r.x + r.w - 2, r.g - 15, { size: 11, color: PAL.ink, weight: 600, align: 'right' })
},
})
// ---------- Zeichnen ----------
const isGrey = (room, look) => room.workerId != null && look.greyRooms.has(room.id)
function drawRoomSign(ctx, room, floor) {
const w = Math.min(room.w - 2, SIGN_MAX_W)
drawSign(ctx, room.x + (room.w - w) / 2, floor.top + 1, w, SIGN_TEXT)
}
function drawRoom(ctx, room, floor, index, t, look, signs) {
// Räume ohne Worker (pult, tresor) werden nie grau
const grey = isGrey(room, look)
const r = {
room,
x: room.x,
w: room.w,
top: floor.top,
g: floor.floorY,
tp: t + room.x * 0.05,
busy: !grey && look.busyRooms.has(room.id),
grey,
color: TEAM[room.team] ?? PAL.metal2,
look,
}
// Teamfarbton hat jeder Raum, auch ein grauer (die Grautönung kommt darüber)
tint(ctx, room.x, floor.top, room.w, floor.h, r.color, TINT_ALPHA)
if (index > 0) px(ctx, room.x, floor.top, 1, floor.h, DIVIDER)
if (Object.hasOwn(ROOM_DRAWERS, room.kind)) ROOM_DRAWERS[room.kind](ctx, r)
// Der Titel steht in der obersten Zeile des Raums, über den Möbeln (ein Monitor deckt sonst den Anfang zu) und mit hellem
// Rand, damit er auch über einem dunklen Bildschirm lesbar bleibt. Im grauen Raum nimmt das Schild (44 breit, 8 hoch) diese
// Zeile ein: über dem Hut der schlafenden Figur sind nur 6 Zeilen frei, Titel (3,3) und Schild (8) passen in den 46 breiten
// Such-Räumen, den einzigen, die grau sein können, nicht übereinander. Name und Branche zeigen dort Möbel und Icon.
if (!grey) label(ctx, room.title, room.x + 2, floor.top + 1, { size: 10, color: PAL.mute, weight: 500, halo: TITLE_HALO })
if (grey) {
tint(ctx, room.x, floor.top, room.w, floor.h, GREY_TINT, GREY_ALPHA)
if (signs) drawRoomSign(ctx, room, floor)
}
}
// Alle Räume aller Etagen. Vorher: drawOutside, drawTower. Nachher: Aufzug hinten, Figuren, Aufzug vorn.
// signs = false lässt die Schilder der grauen Räume aus: die Szene zeichnet sie mit drawSigns erst nach den Figuren,
// sonst deckt der Hut der schlafenden Figur die untere Zeile des Schildtexts zu.
export function drawRooms(ctx, layout, t, look, { signs = true } = {}) {
for (const floor of layout.floors) floor.rooms.forEach((room, index) => drawRoom(ctx, room, floor, index, t, look, signs))
}
// Die Schilder „noch nicht angeschlossen“ der grauen Räume (Gegenstück zu drawRooms mit signs: false)
export function drawSigns(ctx, layout, look) {
for (const floor of layout.floors) {
for (const room of floor.rooms) {
if (isGrey(room, look)) drawRoomSign(ctx, room, floor)
}
}
}
-395
View File
@@ -1,395 +0,0 @@
// Szene des Hustler-Hochhauses: mountTower hängt die Engine an einen Canvas.
// Größe (Breite · dpr, Höhe 4/5), Schleife (höchstens 30 Bilder je Sekunde, dt gekappt), Eingabe (Klick, Ziehen, Strg+Rad),
// Kamera und Lebenszyklus (destroy, StrictMode: mount, destroy, mount). Portiert aus entwuerfe/scene-turm.js (Schleife, Eingabe).
// Pause bei verstecktem Tab und der Schalter „weniger Bewegung“ kommen von außen: setPaused(bool) und die Option reducedMotion
// (die Bühne in React verdrahtet visibilitychange und prefers-reduced-motion). Kein React, kein Appwrite, kein window/document:
// raf, caf und now sind Optionen und fallen erst beim Aufruf von mountTower auf die globalen Funktionen zurück.
import { PAL, setGrid } from './sprites.js'
import { WORLD, buildLayout } from './layout.js'
import { createCamera, fitZoom, viewToWorld, worldToView, zoomAt, panBy, centerOn, pickAgent } from './camera.js'
import { createLift } from './lift.js'
import { createFx } from './fx.js'
import { createLook, drawRooms, drawSigns } from './rooms.js'
import { drawOutside, drawTower, drawPauseVeil } from './building.js'
import { createDirector, drawAgents } from './director.js'
export const FRAME_MS = 33 // zwischen zwei Bildern mindestens so viele Millisekunden (höchstens 30 Bilder je Sekunde)
export const DT_MAX = 0.05 // so viel Zeit rückt die Regie je Bild höchstens vor (nach einem Stillstand springt nichts)
export const CANVAS_MAX_W = 2400 // Breite der Zeichenfläche in Geräte-Pixeln, höchstens
export const ZOOM_STEP = 1.4 // Faktor eines Zoom-Schritts (Knopf „+“, ein Rastenschritt des Rads)
export const DRAG_PX = 4 // so weit darf der Zeiger wandern, ohne dass es ein Ziehen wird (CSS-Pixel)
const MIN_CSS_W = 10 // schmaler gilt die Bühne als nicht sichtbar, dann wird nicht gemessen
const TOWER_MID_X = 122 // Mitte des Turms (x 14..230): der Startausschnitt auf schmalen Bühnen zeigt den ganzen Turm
const PAD_FINE = 4 // Treffer-Toleranz um eine Figur in CSS-Pixeln: Maus
const PAD_COARSE = 10 // … Finger
const WHEEL_UNIT = 100 // deltaY, das beim Strg+Rad einem ZOOM_STEP entspricht
const WHEEL_LINE_PX = 33 // eine Zeile (deltaMode 1) zählt so viele Pixel
const WHEEL_STEPS_MAX = 2 // ein Rad-Ereignis zoomt höchstens um so viele Schritte
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v)
const bindGlobal = (name) => (typeof globalThis[name] === 'function' ? globalThis[name].bind(globalThis) : null)
// Handle für einen Canvas ohne 2D-Kontext: alles ist ein Nichts, damit die Seite nicht abstürzt
function inertHandle() {
const nop = () => {}
return {
destroy: nop, setState: nop, pushEvents: nop, select: nop, setPaused: nop, flush: nop, resize: nop, zoomBy: nop, resetView: nop,
locate: () => null,
stats: () => ({ frames: 0, avgDrawMs: 0, zoom: 1 }),
debug: () => ({ pending: 0, agents: [], lift: 'idle', fx: 0 }),
}
}
/* mountTower(canvas, { onSelect, reducedMotion = false, coarsePointer = false, fontsReady, dpr = 1, raf, caf, now })
→ { destroy(), setState(state), pushEvents(events), select(workerId|null), setPaused(bool), flush(), resize(), zoomBy(faktor),
resetView(), locate(workerId): { x, y } | null, stats(): { frames, avgDrawMs, zoom }, debug() }
Rasterweite je Bild: k = Canvas-Breite / (320 / zoom). Reihenfolge je Bild: Weiß, Außenwelt, Turm, Räume, Aufzug hinten, Figuren,
Schilder der grauen Räume, Aufzug vorn, Effekte, Pausenschleier. onSelect(id | null) kommt nur bei Klicks des Nutzers, nie bei select().
reducedMotion (und fehlendes raf): keine Schleife; setState, pushEvents, select, resize und die Kamera zeichnen je ein Standbild,
pushEvents spielt die Regie sofort zu Ende. fontsReady (Promise): nach dem Laden der Schrift wird einmal neu gezeichnet.
flush() beruhigt die Regie auf einen Schlag (alles zu Ende gespielt, alle Figuren heim, Aufzug und Effekte leer): die Bühne ruft es
beim Zurückkehren in den Tab, damit nichts aus der Zeit davor nachläuft.
locate liefert die Mitte der Trefferfläche in CSS-Pixeln relativ zur Leinwand (null: unbekannt oder außerhalb der Ansicht).
Nach destroy() ist jeder Aufruf wirkungslos. */
export function mountTower(canvas, options = {}) {
const opts = options || {}
const ctx = canvas && typeof canvas.getContext === 'function' ? canvas.getContext('2d') : null
if (!ctx) return inertHandle()
const { onSelect, coarsePointer = false, fontsReady } = opts
const dpr = Number.isFinite(opts.dpr) && opts.dpr > 0 ? opts.dpr : 1
const raf = opts.raf ?? bindGlobal('requestAnimationFrame')
const caf = opts.caf ?? bindGlobal('cancelAnimationFrame')
const now = opts.now ?? (typeof globalThis.performance?.now === 'function' ? () => globalThis.performance.now() : () => Date.now())
const still = !!opts.reducedMotion || typeof raf !== 'function'
const layout = buildLayout()
const look = createLook()
const lift = createLift(layout)
const fx = createFx()
const director = createDirector({ layout, lift, fx, look })
const cam = createCamera()
let destroyed = false
let paused = false
let selectedId = null // Markierung auf einer Figur
let clickedId = null // von einem Klick gesetzt: das folgende select() derselben Kennung verschiebt die Ansicht nicht
let touched = false // der Nutzer hat die Kamera bewegt: dann zieht resize() den Ausschnitt nicht mehr nach
let sized = false // die Zeichenfläche hat ihre Größe von der Bühne: vorher (Bühne unsichtbar, Breite unter 10) wird nichts gezeichnet
let time = 0 // Szenenzeit in Sekunden (steht bei Pause und im Standbild)
let lastT = 0 // Zeitpunkt des letzten Schleifenbildes
let frames = 0
let drawMs = 0
let loopToken = 0 // gilt nur der Rückruf mit der neuesten Nummer (auch ohne caf läuft nie eine zweite Schleife)
let rafHandle = null
let drag = null // { sx, sy, lx, ly, moved } zwischen pointerdown und pointerup
let suppressClick = false // ein Ziehen hat stattgefunden: der nächste click gilt nicht als Auswahl
const looping = () => !still && !paused && !destroyed
// ---------- Zeichnen ----------
function render() {
const k = canvas.width / (WORLD.w / cam.zoom)
setGrid(ctx, { k, ox: -cam.x * k, oy: -cam.y * k })
ctx.fillStyle = PAL.w
ctx.fillRect(0, 0, canvas.width, canvas.height)
drawOutside(ctx)
drawTower(ctx, layout, time, lift)
drawRooms(ctx, layout, time, look, { signs: false })
lift.drawBack(ctx)
drawAgents(ctx, director, selectedId, time)
drawSigns(ctx, layout, look)
lift.drawFront(ctx)
fx.draw(ctx)
drawPauseVeil(ctx, look)
}
// Ein Bild zeichnen und messen (Schleife, Standbild, sofortige Bilder nach setState und pushEvents)
function draw() {
if (destroyed || !sized) return
const t0 = now()
render()
frames += 1
drawMs += Math.max(0, now() - t0)
}
// Nach einer Änderung von Kamera oder Markierung: die laufende Schleife zeichnet von selbst, sonst kommt ein Standbild
function refresh() {
if (!looping()) draw()
}
// Im Standbild gibt es keine Zeit: kurze Aufleuchten (neuer Pokal, Tresor-Blitz) blieben sonst für immer stehen
function calm() {
look.trophyPop = 0
look.vaultFlash = 0
}
// ---------- Schleife ----------
function schedule() {
if (!looping()) return
loopToken += 1
const token = loopToken
rafHandle = raf(() => frame(token))
}
function stopLoop() {
loopToken += 1
if (rafHandle !== null && typeof caf === 'function') caf(rafHandle)
rafHandle = null
}
// Erst das nächste Bild bestellen, dann arbeiten: ein Fehler im Bild lässt die Schleife nicht sterben
function frame(token) {
if (token !== loopToken || !looping()) return
rafHandle = null
schedule()
const t = now()
if (t - lastT < FRAME_MS) return
const dt = Math.min(DT_MAX, Math.max(0, (t - lastT) / 1000))
lastT = t
time += dt
director.update(dt)
draw()
}
// ---------- Größe und Kamera ----------
function syncTouchAction() {
if (canvas.style) canvas.style.touchAction = cam.zoom > 1 ? 'none' : 'pan-y'
}
function resize() {
if (destroyed) return
const w = canvas.getBoundingClientRect().width
if (!(w >= MIN_CSS_W)) return
const pxW = Math.min(CANVAS_MAX_W, Math.round(w * dpr))
const pxH = Math.round((pxW * WORLD.h) / WORLD.w)
if (canvas.width !== pxW) canvas.width = pxW
if (canvas.height !== pxH) canvas.height = pxH
sized = true
if (!touched) {
cam.zoom = fitZoom(w)
cam.x = 0
cam.y = 0
centerOn(cam, TOWER_MID_X, 0)
}
syncTouchAction()
draw()
}
function moved() {
touched = true
syncTouchAction()
refresh()
}
// Ort eines Zeigerereignisses als Bruchteile der Ansicht (null bei unbrauchbaren Werten)
function viewPoint(e) {
const r = canvas.getBoundingClientRect()
const vx = (e.clientX - r.left) / r.width
const vy = (e.clientY - r.top) / r.height
return Number.isFinite(vx) && Number.isFinite(vy) ? { r, vx, vy } : null
}
// ---------- Eingabe ----------
function onClick(e) {
if (destroyed) return
if (suppressClick) {
suppressClick = false
return
}
const p = viewPoint(e)
if (!p) return
const w = viewToWorld(cam, p.vx, p.vy)
const unitPx = p.r.width / (WORLD.w / cam.zoom) // CSS-Pixel je Welt-Einheit
const id = pickAgent(director.boxes(), w.x, w.y, (coarsePointer ? PAD_COARSE : PAD_FINE) / unitPx)
selectedId = id
clickedId = id
refresh()
if (typeof onSelect === 'function') onSelect(id)
}
function onPointerDown(e) {
if (destroyed || e.button > 0) return
suppressClick = false
drag = { sx: e.clientX, sy: e.clientY, lx: e.clientX, ly: e.clientY, moved: false }
try {
if (typeof canvas.setPointerCapture === 'function') canvas.setPointerCapture(e.pointerId)
} catch {
// ungültige Zeiger-Kennung: ohne Erfassung geht es auch
}
}
function onPointerMove(e) {
if (destroyed || !drag) return
if (!drag.moved) {
if (Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy) <= DRAG_PX) return
drag.moved = true
suppressClick = true
}
if (cam.zoom > 1) {
const r = canvas.getBoundingClientRect()
const dx = (e.clientX - drag.lx) / r.width
const dy = (e.clientY - drag.ly) / r.height
if (Number.isFinite(dx) && Number.isFinite(dy)) {
panBy(cam, -dx, -dy)
moved()
}
}
drag.lx = e.clientX
drag.ly = e.clientY
}
function onPointerEnd(e) {
if (!drag) return
drag = null
try {
if (typeof canvas.releasePointerCapture === 'function') canvas.releasePointerCapture(e.pointerId)
} catch {
// schon freigegeben
}
}
// Nur mit Strg (Pinch auf dem Trackpad meldet Strg+Rad); ohne Strg scrollt die Seite wie gewohnt
function onWheel(e) {
if (destroyed || !e.ctrlKey) return
e.preventDefault()
const p = viewPoint(e)
const dy = (e.deltaMode === 1 ? WHEEL_LINE_PX : 1) * e.deltaY
const steps = clamp(-dy / WHEEL_UNIT, -WHEEL_STEPS_MAX, WHEEL_STEPS_MAX)
zoomAt(cam, Math.pow(ZOOM_STEP, steps), p ? p.vx : 0.5, p ? p.vy : 0.5)
moved()
}
const listeners = [
['click', onClick],
['pointerdown', onPointerDown],
['pointermove', onPointerMove],
['pointerup', onPointerEnd],
['pointercancel', onPointerEnd],
['wheel', onWheel, { passive: false }],
]
for (const [type, fn, listenerOptions] of listeners) canvas.addEventListener(type, fn, listenerOptions)
// ---------- Handle ----------
const handle = {
destroy() {
if (destroyed) return
destroyed = true
stopLoop()
for (const [type, fn] of listeners) canvas.removeEventListener(type, fn)
drag = null
},
setState(state) {
if (destroyed) return
director.applyState(state)
if (still) calm()
draw()
},
pushEvents(events) {
if (destroyed) return
director.pushEvents(events)
if (still) {
director.flush()
calm()
}
draw()
},
// Markierung setzen (Auswahl von außen, ohne onSelect); bei Zoom > 1 in die Mitte holen, außer der Klick selbst hat sie gesetzt
select(workerId) {
if (destroyed) return
const a = typeof workerId === 'string' && Object.hasOwn(director.byId, workerId) ? director.byId[workerId] : null
selectedId = a ? a.id : null
const byClick = a !== null && clickedId === a.id
clickedId = null
if (a && cam.zoom > 1 && !byClick) {
// in die Mitte der Trefferfläche (Figur samt Hut); vor dem ersten Bild gibt es sie noch nicht
const b = a.box
centerOn(cam, b ? b.x + b.w / 2 : a.x + 6 * a.scale, b ? b.y + b.h / 2 : a.y + 7 * a.scale)
moved()
} else {
refresh()
}
},
flush() {
if (destroyed) return
director.flush()
if (still) calm()
draw()
},
setPaused(value) {
if (destroyed) return
const next = !!value
if (next === paused) return
paused = next
if (paused) {
stopLoop()
} else {
lastT = now()
schedule()
}
},
resize,
zoomBy(factor) {
if (destroyed) return
zoomAt(cam, factor, 0.5, 0.5)
moved()
},
resetView() {
if (destroyed) return
cam.zoom = 1
cam.x = 0
cam.y = 0
moved()
},
locate(workerId) {
if (destroyed || typeof workerId !== 'string' || !Object.hasOwn(director.byId, workerId)) return null
const box = director.byId[workerId].box
if (!box) return null
const v = worldToView(cam, box.x + box.w / 2, box.y + box.h / 2)
if (!(v.vx >= 0 && v.vx <= 1 && v.vy >= 0 && v.vy <= 1)) return null
const r = canvas.getBoundingClientRect()
return { x: v.vx * r.width, y: v.vy * r.height }
},
stats() {
return { frames, avgDrawMs: frames > 0 ? drawMs / frames : 0, zoom: cam.zoom }
},
debug() {
return {
pending: director.pending(),
agents: director.agents.map((a) => ({ id: a.id, x: a.x, y: a.y, floor: a.floor, pose: a.pose, grey: a.grey })),
lift: lift.state,
fx: fx.size(),
}
},
}
// ---------- Start ----------
resize()
if (looping()) {
lastT = now()
schedule()
}
if (fontsReady && typeof fontsReady.then === 'function') {
fontsReady.then(() => draw(), () => {})
}
return handle
}
-329
View File
@@ -1,329 +0,0 @@
// Engine-Grundlagen des Hustler-Hochhauses: Palette, Raster, Figuren mit Posen, Text, Sprechblasen.
// Portiert aus entwuerfe/sprites.js (ES5-IIFE mit fester Skala S = 3). Neu: Raster je Kontext statt fester Skala,
// Posen (stand, type, coffee, sleep), Graustufe für nicht angeschlossene Worker, Warnzeichen.
// Kein React, kein Appwrite, kein window/document: alles Zeichnen geht über den übergebenen Canvas-Kontext.
export const PAL = Object.freeze({
o: '#3a2f3d', // Umriss (dunkles Pflaume-Grau statt Schwarz)
b: '#d98a66', // Körper (Ton)
s: '#b8694a', // Körper Schatten
h: '#f2b897', // Körper Licht
e: '#2b2230', // Augen
w: '#ffffff',
g: '#9aa3b2', // Papier-Umriss
p: '#c3cad6', // Papier-Zeilen
ink: '#1a1f36',
mute: '#697386',
line: '#ebeef1',
floor: '#f6f7f9',
floor2: '#eef0f4',
wood: '#c9a27e', wood2: '#a8825f',
metal: '#c3c8d2', metal2: '#8c93a3',
screen: '#1f2a44',
red: '#d6455d', green: '#3aa66b', gold: '#e8b730', gold2: '#b98a12',
shadow: 'rgba(26,31,54,0.12)',
})
// Hut-Farben zeigen das Modell: Opus = Krone, Sonnet = Kappe, Haiku = Mütze, Overseer = Kommandomütze
export const HAT = Object.freeze({
opus: Object.freeze(['#e8b730', '#b98a12']),
sonnet: Object.freeze(['#4a7fd6', '#2f5ea8']),
haiku: Object.freeze(['#5cbf8a', '#3a9466']),
overseer: Object.freeze(['#7a5cd6', '#54399f']),
})
// Ein Farbton pro Station/Team
export const TEAM = Object.freeze({
radar: '#4a90d9', // Anfragen-Radar
aerzte: '#3fb6b2', // Ärzte & Praxen
handwerk: '#e8873a', // Handwerk
selbst: '#c66bd1', // Selbstständige
laden: '#8fb83a', // Läden & Gastro
bewertung: '#e0b13a',
werkstatt: '#d98a66',
pruefung: '#d6455d',
freigabe: '#7a5cd6',
archiv: '#8c93a3',
tresor: '#3aa66b',
})
export const FONT_FAMILY = '"Pixelify Sans", ui-monospace, Menlo, monospace'
// ---------- Raster ----------
// Raster je Kontext: k = Geräte-Pixel je logischer Einheit, (ox, oy) = Versatz in Geräte-Pixeln.
// Ohne setGrid gilt k = 3 (die Skala der Vorlage). Ein WeakMap hält den Zustand außerhalb des Kontexts.
const DEFAULT_GRID = Object.freeze({ k: 3, ox: 0, oy: 0 })
const grids = new WeakMap()
const gridOf = (ctx) => grids.get(ctx) || DEFAULT_GRID
export function setGrid(ctx, { k, ox = 0, oy = 0 }) {
grids.set(ctx, { k, ox, oy })
}
// Rechteck in logischen Einheiten. Beide Kanten werden einzeln gerundet (die rechte Kante eines Rechtecks ist die
// linke des nächsten: keine Nähte), die Größe beträgt mindestens ein Geräte-Pixel.
export function px(ctx, x, y, w, h, color) {
const { k, ox, oy } = gridOf(ctx)
const left = Math.round(x * k + ox)
const top = Math.round(y * k + oy)
ctx.fillStyle = color
ctx.fillRect(
left,
top,
Math.max(1, Math.round((x + w) * k + ox) - left),
Math.max(1, Math.round((y + h) * k + oy) - top),
)
}
// Fläche mit Deckkraft (save/restore gleichen sich aus)
export function tint(ctx, x, y, w, h, color, alpha) {
ctx.save()
ctx.globalAlpha = alpha
px(ctx, x, y, w, h, color)
ctx.restore()
}
// ---------- Sprite-Karten ----------
const BODY = [
'....oooo....',
'..oohhhboo..',
'.ohhbbbbbbo.',
'.ohbbbbbbbo.',
'obbebbbbebso',
'obbebbbbebso',
'obbbbbbbbbso',
'obbbbbbbbbso',
'.obbbbbbbso.',
'.ossbbbbsso.',
'..oossssoo..',
]
// Schlafen: die obere Augenzeile wird zu Körperfarbe, nur die Zeile darunter bleibt (zwei Augenpunkte)
const BODY_SLEEP = BODY.map((row, i) => (i === 4 ? row.replace(/e/g, 'b') : row))
const LEGS = [
['...oo..oo...', '...oo..oo...'], // stehen
['..oo....oo..', '.oo......oo.'], // Schritt A
['....oooo....', '....oooo....'], // Schritt B
]
const HATS = {
opus: ['...H.HH.H...', '...HHHHHH...', '...SSSSSS...'],
sonnet: ['....HHHH....', '...HHHHHH...', '...SSSSSSSS.'],
haiku: ['.....HH.....', '...HHHHHH...', '...HSHSHSH..'],
overseer: ['...HHHHHH...', '..HHHHwHHH..', '..SSSSSSSS..'],
}
const VISOR = ['obVwVVVVVVso', 'obVVVVVVVVso'] // ersetzt die Augenzeilen 4–5 beim Overseer
const PAPER = ['gggggg', 'gwwwwg', 'gwppwg', 'gwwwwg', 'gwppwg', 'gwwwwg', 'gggggg']
const ARROW = ['ooooo', '.ooo.', '..o..']
const ALERT = ['rrr', 'rrr', 'rrr', '.r.', '.r.', '...', '.r.'] // 3 × 7: „!“
// Umriss um eine Karte: jede leere Zelle (auch diagonal) neben einer belegten wird 'o'; die Karte wächst um 1 je Seite
function outlined(map) {
const h = map.length
const w = map[0].length
const at = (r, c) => (r >= 0 && r < h && c >= 0 && c < w ? map[r][c] : '.')
const nextToInk = (r, c) => [-1, 0, 1].some((dr) => [-1, 0, 1].some((dc) => at(r + dr, c + dc) !== '.'))
const rows = []
for (let r = -1; r <= h; r++) {
let row = ''
for (let c = -1; c <= w; c++) row += at(r, c) === '.' && nextToInk(r, c) ? 'o' : at(r, c)
rows.push(row)
}
return rows
}
const ALERT_OUTLINED = outlined(ALERT)
// Grautöne für nicht angeschlossene Worker (Umriss, Augen und Papier bleiben)
const GREY = { b: '#aeb3bd', s: '#8a909c', h: '#c9cdd5', H: '#9ca2ae', S: '#7f8693' }
function workerColors(hat, grey) {
const hc = HAT[hat] || HAT.haiku
if (grey) return { o: PAL.o, b: GREY.b, s: GREY.s, h: GREY.h, e: PAL.e, w: PAL.w, g: PAL.g, p: PAL.p, H: GREY.H, S: GREY.S, V: GREY.S }
return { o: PAL.o, b: PAL.b, s: PAL.s, h: PAL.h, e: PAL.e, w: PAL.w, g: PAL.g, p: PAL.p, H: hc[0], S: hc[1], V: HAT.overseer[1] }
}
// Zeichnet eine Karte zellenweise: (x, y) = linke obere Ecke in logischen Einheiten, sc = Zellgröße, flip spiegelt
function drawMap(ctx, map, x, y, colors, flip, sc) {
for (let r = 0; r < map.length; r++) {
const row = map[r]
for (let c = 0; c < row.length; c++) {
const ch = row[c]
if (ch === '.') continue
const col = colors[ch]
if (!col) continue
const cx = flip ? row.length - 1 - c : c
px(ctx, x + cx * sc, y + r * sc, sc, sc, col)
}
}
}
function drawSelect(ctx, x, y, sc, t) {
const hop = Math.round(Math.sin(t * 4)) * sc
drawMap(ctx, ARROW, x, y + hop, { o: PAL.ink }, false, sc)
}
// Kaffeetasse an der Hand (3 × 3) mit einem Dampfpixel, der mit t flackert
function drawCup(ctx, x, y, sc, flip, t) {
const cx = x + (flip ? -1 : 10) * sc
const cy = y + 6 * sc
px(ctx, cx, cy, 3 * sc, 3 * sc, PAL.metal2)
px(ctx, cx, cy, 3 * sc, sc, PAL.wood2)
if (Math.floor(t * 3) % 2 === 0) px(ctx, cx + sc, cy - 2 * sc, sc, sc, PAL.g)
}
// Kleines „z“ aus drei Rechtecken über dem Hut, steigt in ganzen Pixeln mit t (alle 2,5 s von vorn)
function drawSleepZ(ctx, x, y, sc, flip, t) {
const rise = Math.floor((Math.max(0, t) * 2) % 5)
const zx = x + (flip ? 0 : 9) * sc
const zy = y - (6 + rise) * sc
px(ctx, zx, zy, 3 * sc, sc, PAL.mute)
px(ctx, zx + sc, zy + sc, sc, sc, PAL.mute)
px(ctx, zx, zy + 2 * sc, 3 * sc, sc, PAL.mute)
}
/* Zeichnet einen kleinen Worker. (x, y) = linke obere Ecke des Körpers (ohne Hut), logische Einheiten.
hat: 'opus' | 'sonnet' | 'haiku' | 'overseer'; frame: 0 stehen, 1/2 laufen; facing: 1 rechts, -1 links;
carrying: Dokument in der Hand; scale: 1 normal, 2 groß; selected: Pfeil über dem Hut;
pose: 'stand' und 'type' sehen gleich aus (das Wippen macht die Regie), 'coffee' mit Tasse, 'sleep' mit z;
grey: halb durchsichtig in Grautönen (nicht angeschlossen), t: Zeit in Sekunden für z, Dampf und Pfeil.
Rückgabe: Trefferfläche { x, y, w, h } in logischen Einheiten (bei Graustufe unverändert). */
export function drawWorker(ctx, x, y, { hat, frame = 0, facing = 1, carrying = false, scale = 1, selected = false, pose = 'stand', grey = false, t = 0 } = {}) {
const sc = scale
const flip = facing === -1
const bob = frame === 1 ? -1 * sc : 0
const colors = workerColors(hat, grey)
if (grey) {
ctx.save()
ctx.globalAlpha = 0.55
}
px(ctx, x + 2 * sc, y + 13 * sc, 8 * sc, 1 * sc, PAL.shadow)
drawMap(ctx, LEGS[frame] || LEGS[0], x, y + 11 * sc, colors, flip, sc)
drawMap(ctx, pose === 'sleep' ? BODY_SLEEP : BODY, x, y + bob, colors, flip, sc)
if (hat === 'overseer') drawMap(ctx, VISOR, x, y + bob + 4 * sc, colors, flip, sc)
drawMap(ctx, HATS[hat] || HATS.haiku, x, y + bob - 2 * sc, colors, flip, sc)
if (carrying) drawMap(ctx, PAPER, x + (flip ? -2 : 8) * sc, y + bob + 6 * sc, colors, false, sc)
if (pose === 'coffee') drawCup(ctx, x, y + bob, sc, flip, t)
else if (pose === 'sleep') drawSleepZ(ctx, x, y + bob, sc, flip, t)
if (grey) ctx.restore()
if (selected) drawSelect(ctx, x + 3.5 * sc, y + bob - 7 * sc, sc, t)
return { x: x - (carrying && flip ? 2 * sc : 0), y: y - 2 * sc, w: (carrying ? 14 : 12) * sc, h: 16 * sc }
}
export function drawPaper(ctx, x, y) {
drawMap(ctx, PAPER, x, y, { g: PAL.g, w: PAL.w, p: PAL.p }, false, 1)
}
// Rotes „!“ mit Umriss (bleibt stehen, solange ein Worker den Status fehler hat).
// (x, y) = Mitte der Unterkante, wie die Spitze der Sprechblase: das Zeichen steht über diesem Punkt.
export function drawAlertMark(ctx, x, y) {
const cols = ALERT_OUTLINED[0].length
drawMap(ctx, ALERT_OUTLINED, x - cols / 2, y - ALERT_OUTLINED.length, { r: PAL.red, o: PAL.o }, false, 1)
}
// ---------- Text (Pixel-Schrift, Größe wächst mit dem Raster) ----------
const fontOf = (weight, devPx) => `${weight} ${Math.round(devPx * 100) / 100}px ${FONT_FAMILY}`
/* Text an (x, y) in logischen Einheiten (y = Oberkante der Zeile). Schrift = size * k / 3 Geräte-Pixel,
bei k = 3 also size px. bg: Farbe eines Kastens hinter dem Text; halo: Farbe eines dünnen Rands um die Buchstaben
(lesbar auch über dunklen Flächen wie einem Monitor); align: 'left' | 'center' | 'right'. */
export function label(ctx, text, x, y, { size = 12, color = PAL.ink, weight = 500, align = 'left', bg, halo } = {}) {
const { k, ox, oy } = gridOf(ctx)
const f = k / 3
const str = String(text ?? '')
const dx = Math.round(x * k + ox)
const dy = Math.round(y * k + oy)
ctx.font = fontOf(weight, size * f)
ctx.textBaseline = 'top'
ctx.textAlign = align
if (bg) {
const tw = ctx.measureText(str).width
const left = align === 'center' ? dx - tw / 2 : align === 'right' ? dx - tw : dx
ctx.fillStyle = bg
ctx.fillRect(Math.round(left - 4 * f), Math.round(dy - 2 * f), Math.round(tw + 8 * f), Math.round(size * f + 5 * f))
}
if (halo) {
ctx.lineJoin = 'round'
ctx.lineWidth = Math.max(2, Math.round(3 * f))
ctx.strokeStyle = halo
ctx.strokeText(str, dx, dy)
}
ctx.fillStyle = color
ctx.fillText(str, dx, dy)
ctx.textAlign = 'left'
}
function deviceRect(ctx, x, y, w, h, color) {
ctx.fillStyle = color
ctx.fillRect(x, y, w, h)
}
// Breite der Zeichenfläche in Geräte-Pixeln (ohne Canvas 960, wie die Welt bei Raster 3)
const canvasWidth = (ctx) => (ctx.canvas && ctx.canvas.width > 0 ? ctx.canvas.width : 960)
/* Sprechblase über einem Worker: (x, y) = Spitze der Blase (logisch). Maße wachsen mit dem Raster; die Blase
wird in Geräte-Pixeln in die Breite von ctx.canvas geklemmt (Rand 2 bei k = 3, ohne Canvas 960). */
export function drawBubble(ctx, x, y, text) {
const str = String(text ?? '')
if (str === '') return
const { k, ox, oy } = gridOf(ctx)
const f = k / 3
const u = (n) => Math.max(1, Math.round(n * f))
ctx.font = fontOf(500, 12 * f)
ctx.textBaseline = 'top'
ctx.textAlign = 'left'
const pad = u(7)
const edge = u(2)
const bw = Math.ceil(ctx.measureText(str).width) + 2 * pad
const bh = u(22)
const tipX = Math.round(x * k + ox)
const by = Math.round(y * k + oy) - bh - u(6)
const maxW = canvasWidth(ctx)
const bx = Math.max(edge, Math.min(maxW - bw - edge, Math.round(tipX - bw / 2)))
// Die Spitze bleibt am Kasten, auch wenn der Kasten wegen des Rands verschoben wurde
const tailW = u(6)
const tx = Math.max(bx + Math.round(tailW / 2), Math.min(bx + bw - Math.round(tailW / 2), tipX))
deviceRect(ctx, bx - edge, by - edge, bw + 2 * edge, bh + 2 * edge, PAL.o)
deviceRect(ctx, bx, by, bw, bh, PAL.w)
deviceRect(ctx, tx - Math.round(tailW / 2), by + bh + edge, tailW, u(3), PAL.o)
deviceRect(ctx, tx - u(1), by + bh + edge + u(3), u(2), u(2), PAL.o)
ctx.fillStyle = PAL.ink
ctx.fillText(str, bx + pad, by + u(5))
}
/* Sprechblase neben einem Worker, für Otto an der Oberkante der Welt, wo über dem Kopf kein Platz ist (Vorlage: sideBubble).
leftX/rightX = linke und rechte Körperkante (logisch), y = Oberkante der Blase. Die Blase steht links vom Körper und ihre
Spitze zeigt nach rechts auf leftX; fehlt links der Platz (Rand 4), steht sie rechts davon mit der Spitze auf rightX,
in die Breite von ctx.canvas geklemmt. Maße und Schrift wachsen mit dem Raster. */
export function drawSideBubble(ctx, text, { leftX, rightX, y }) {
const str = String(text ?? '')
if (str === '') return
const { k, ox, oy } = gridOf(ctx)
const f = k / 3
const u = (n) => Math.max(1, Math.round(n * f))
ctx.font = fontOf(500, 12 * f)
ctx.textBaseline = 'top'
ctx.textAlign = 'left'
const pad = u(7)
const edge = u(2)
const gap = u(9) // Blasenrand bis Körperkante: Rahmen, Spitze (8) und ein Pixel Luft
const bw = Math.ceil(ctx.measureText(str).width) + 2 * pad
const bh = u(22)
const by = Math.round(y * k + oy)
let bx = Math.round(leftX * k + ox) - gap - bw
const onLeft = bx >= u(4)
if (!onLeft) bx = Math.max(edge, Math.min(canvasWidth(ctx) - bw - edge, Math.round(rightX * k + ox) + gap))
deviceRect(ctx, bx - edge, by - edge, bw + 2 * edge, bh + 2 * edge, PAL.o)
deviceRect(ctx, bx, by, bw, bh, PAL.w)
if (onLeft) {
deviceRect(ctx, bx + bw + edge, by + u(7), u(4), u(4), PAL.o)
deviceRect(ctx, bx + bw + u(6), by + u(8), u(2), u(2), PAL.o)
} else {
deviceRect(ctx, bx - u(6), by + u(7), u(4), u(4), PAL.o)
deviceRect(ctx, bx - u(8), by + u(8), u(2), u(2), PAL.o)
}
ctx.fillStyle = PAL.ink
ctx.fillText(str, bx + pad, by + u(5))
}
+11 -2
View File
@@ -1,5 +1,5 @@
/* Press Start 2P (SIL OFL 1.1, Lizenztext: public/fonts/OFL-PressStart2P.txt) für Zahlen und Überschriften der Hustler-Oberfläche.
Selbst gehostet, kein Abruf bei Google Fonts. Pixelify Sans ist schon in workers.css eingebunden. */
/* Die zwei Schriften der Hustler-Oberfläche, beide selbst gehostet (SIL OFL 1.1, Lizenztexte neben den Dateien in public/fonts/), kein Abruf bei Google Fonts:
Press Start 2P für Zahlen und Überschriften (OFL-PressStart2P.txt) und Pixelify Sans für Fließtext, Namen und Sprechblasen (OFL-PixelifySans.txt). */
@font-face {
font-family: "Press Start 2P";
src: url("/fonts/PressStart2P-Regular.woff2") format("woff2");
@@ -7,3 +7,12 @@
font-style: normal;
font-display: swap;
}
/* nur der Latin-Schnitt, variabel mit allen Gewichten */
@font-face {
font-family: "Pixelify Sans";
src: url("/fonts/PixelifySans-Variable.woff2") format("woff2");
font-weight: 400 700;
font-style: normal;
font-display: swap;
}
+4 -260
View File
@@ -1,152 +1,7 @@
/* Hustler (Workers P2): Hochhaus, Kennzahlen, Worker-Karte, Liste, Einstellungen. Weißes Theme: Haarlinien statt Karten, nur Tokens aus global.css. */
/* Schrift selbst gehostet (SIL OFL 1.1, Lizenztext liegt daneben), nur der Latin-Schnitt mit allen Gewichten */
@font-face {
font-family: "Pixelify Sans";
src: url("/fonts/PixelifySans-Variable.woff2") format("woff2");
font-weight: 400 700;
font-style: normal;
font-display: swap;
}
.wk-page .page-head h1 { font-family: "Pixelify Sans", var(--font); letter-spacing: 0; }
/* ---------- Kopfzeile mit Pausenschalter ---------- */
.wk-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 12px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
margin-bottom: 20px;
}
.wk-status { color: var(--text-2); }
/* Meldungen zwischen Kopfzeile und Kennzahlen (Notbremse, Fehler) halten Abstand zu den Kennzahlen */
.wk-toolbar ~ .msg { margin-bottom: 16px; }
/* ---------- Kennzahlen ---------- */
.wk-kpis.stats {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 16px 32px;
margin: 0 0 24px;
}
.wk-kpis .stat { min-width: 0; font-variant-numeric: tabular-nums; }
.wk-kpis .stat .sub { min-height: 16px; }
/* ---------- Hochhaus und Seitenspalte ---------- */
.wk-main {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 32px;
align-items: start;
}
.wk-side {
min-width: 0;
padding-left: 24px;
border-left: 1px solid var(--line);
}
.wk-stage { position: relative; min-width: 0; }
.wk-canvas {
display: block;
width: 100%;
aspect-ratio: 5 / 4;
image-rendering: pixelated;
cursor: pointer;
touch-action: pan-y;
}
/* unten rechts: oben rechts läge er über dem Schild der Kommandobrücke */
.wk-zoom {
position: absolute;
bottom: 8px;
right: 8px;
display: flex;
gap: 4px;
}
.wk-zoom .btn { min-width: 32px; }
/* ---------- Worker-Karte ---------- */
.wk-card { position: relative; padding-bottom: 20px; scroll-margin-top: 12px; }
.wk-card > .btn-quiet { position: absolute; top: 0; right: 0; }
.wk-card h3 { margin: 20px 0 8px; font-size: 13px; line-height: 20px; font-weight: 600; color: var(--text-2); }
.wk-portrait {
display: block;
width: 72px;
height: 96px;
margin-bottom: 8px;
image-rendering: pixelated;
}
.wk-name { font-family: "Pixelify Sans", var(--font); font-size: 20px; line-height: 26px; font-weight: 600; overflow-wrap: anywhere; }
.wk-role { color: var(--text-2); margin-bottom: 8px; }
.wk-hint { color: var(--text-2); font-size: 13px; }
.wk-chip {
display: inline-block;
margin-right: 8px;
padding: 0 6px;
border-radius: var(--radius);
font-size: 12px;
line-height: 20px;
font-weight: 500;
background: var(--grey-bg);
color: var(--grey-fg);
}
.wk-chip-opus { background: var(--orange-bg); color: var(--orange-fg); }
.wk-chip-sonnet { background: var(--blue-bg); color: var(--blue-fg); }
.wk-chip-haiku { background: var(--green-bg); color: var(--green-fg); }
.wk-chip-overseer { background: var(--violet-bg); color: var(--violet-fg); }
.wk-facts, .wk-counters dl { margin: 12px 0 0; }
.wk-facts > div, .wk-counters dl > div {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 6px 0;
border-top: 1px solid var(--line);
font-variant-numeric: tabular-nums;
}
.wk-facts dt, .wk-counters dt { color: var(--text-2); flex: none; }
.wk-facts dd, .wk-counters dd { text-align: right; min-width: 0; overflow-wrap: anywhere; }
.wk-trace { list-style: none; margin: 0; padding: 0; }
.wk-trace li { padding: 6px 0; border-top: 1px solid var(--line); font-size: 13px; line-height: 18px; overflow-wrap: anywhere; }
.wk-trace-time { color: var(--text-3); font-variant-numeric: tabular-nums; }
/* ---------- Liste aller Worker ---------- */
.wk-list { margin-top: 8px; border-top: 1px solid var(--line); }
.wk-list ul { list-style: none; margin: 0; padding: 0; }
.wk-list-floor { margin: 16px 0 4px; font-size: 12px; line-height: 16px; font-weight: 600; color: var(--text-3); }
.wk-list button {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 32px;
padding: 4px 8px;
border-radius: var(--radius);
text-align: left;
transition: background-color 0.12s;
}
.wk-list button:hover { background: var(--bg-hover); }
.wk-list button[aria-pressed="true"] { background: var(--accent-soft); font-weight: 600; }
.wk-list button > span:last-child { color: var(--text-2); font-size: 12px; font-weight: 400; }
.wk-list button:focus-visible { outline-offset: -2px; }
.wk-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-3); }
.wk-dot-arbeitet { background: var(--green-fg); }
.wk-dot-wartet { background: var(--nav-dot); }
.wk-dot-leerlauf { background: var(--text-3); }
.wk-dot-grau { background: transparent; box-shadow: inset 0 0 0 1.5px var(--line-strong); }
.wk-dot-fehler { background: var(--danger); }
/* Hustler: die weißen Bausteine, die in der Pixelwelt weiter gebraucht werden. Hochhaus, Pult und Keller stehen in styles/hustler/;
hier liegen nur noch der Einstellungen-Dialog (WorkersSettingsDialog) und das Interessenten-Panel (ProspectPanel), beide im weißen
Dialog-Stil der übrigen Seiten: Haarlinien statt Karten, nur Tokens aus global.css. Jede Regel beginnt mit einer wk-Klasse; die Schrift
„Pixelify Sans“ steht in styles/hustler/schrift.css. */
/* ---------- Einstellungen (Dialog) ---------- */
@@ -171,52 +26,6 @@
}
.wk-halt .wk-set-title { color: var(--red-fg); }
/* ---------- Freigabe-Pult ---------- */
.wk-pult-filters { margin-bottom: 16px; }
.wk-pult-filters ~ .msg { margin-bottom: 12px; }
.wk-pult-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
.wk-pult-row { min-width: 0; padding: 16px 0; border-bottom: 1px solid var(--line); }
.wk-pult-row .msg { margin-top: 8px; }
.wk-pult-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; }
.wk-pult-title { min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
.wk-pult-meta {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 2px 16px;
margin: 4px 0 12px;
font-size: 13px;
color: var(--text-2);
}
.wk-pult-original { margin: 0 0 12px; }
.wk-pult-original summary { cursor: pointer; color: var(--text-2); font-size: 13px; }
.wk-pult-original-text { margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.wk-pult-row .wk-pult-draft { display: block; width: 100%; min-height: 140px; margin: 0 0 12px; }
.wk-pult-row .wk-pult-actions { align-items: center; gap: 8px 16px; }
/* Aufgeklappte Felder unter den Aktionen: „Als Mensch antworten“ (reply) und „Antwort des Kunden einfügen“ (paste). Haarlinie darüber, volle Breite. */
.wk-pult-row .wk-pult-reply, .wk-pult-row .wk-pult-paste {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--line);
}
.wk-pult-reply .wk-hint, .wk-pult-paste .wk-hint { margin: 0; }
.wk-pult-reply .textarea, .wk-pult-paste .textarea { display: block; width: 100%; min-height: 140px; }
.wk-pult-panel-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; }
/* „Abbrechen“ links vom Hauptknopf */
.wk-pult-panel-btns { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.wk-pult-count { color: var(--text-2); font-size: 13px; font-variant-numeric: tabular-nums; }
.wk-pult-count.over { color: var(--red-fg); }
/* ---------- Interessenten-Panel (Dialog) ---------- */
/* Fremdtext (Anfrage, Nachrichten): Zeilenumbrüche des Absenders bleiben, lange Wörter und Adressen brechen um */
@@ -235,76 +44,11 @@
.wk-prospect-msg { min-width: 0; }
.wk-prospect-msg-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; margin-bottom: 4px; font-size: 13px; color: var(--text-2); }
/* ---------- Bot-Leads ---------- */
.wk-bl { min-width: 0; }
.wk-bl > .wk-hint { margin: 0 0 12px; }
.wk-bl .search { max-width: 100%; }
.wk-bl-single { gap: 8px 16px; }
/* Die Tabelle scrollt auf dem Handy waagerecht in ihrem Rahmen, nicht die Seite */
.wk-bl-scroll { max-width: 100%; overflow-x: auto; }
.wk-bl-table { min-width: 760px; }
/* break-word statt anywhere: anywhere ließe die Spalten auf ein Zeichen schrumpfen („Handwer-k“); lange Namen brechen, der Rahmen scrollt */
.wk-bl-table td { overflow-wrap: break-word; }
/* Der Ort steht in .cell-sub, und global.css hat `.table td .cell-sub { overflow-wrap: anywhere }` (0,2,1): die Regel sitzt auf dem Element selbst und schlägt das
break-word der Zelle. Mit anywhere schrumpft die Spalte Betrieb unter das Wort („Kaiserslau-tern“, besonders in der Einzelansicht am Handy). Hier drei Klassen
(0,3,1): streng größer, nicht gleich, damit nicht die Reihenfolge der Stylesheets im Bündel entscheidet. global.css bleibt, andere Tabellen brauchen anywhere. */
.wk-bl .wk-bl-table td .cell-sub { overflow-wrap: break-word; }
.wk-bl-nowrap { white-space: nowrap; font-variant-numeric: tabular-nums; }
/* Gekürzte Einträge (Daten nach der Frist entfernt) bleiben als graue Zeile stehen */
.wk-bl-cut td { color: var(--text-2); } /* --text-3 (#8a93a3) hätte nur etwa 3:1; --text-2 (#5e6678) hat mehr als 5:1 auf Weiß und auf dem Zeilengrund */
.wk-bl-tag {
display: inline-block;
padding: 0 6px;
border-radius: var(--radius);
font-size: 12px;
line-height: 20px;
font-weight: 500;
background: var(--grey-bg);
color: var(--grey-fg);
}
.wk-bl-pager { display: flex; gap: 8px; }
/* „Erneut versuchen“ unter der Fehlermeldung (die Meldung selbst hat keinen Abstand nach unten) */
.wk-bl-retry { margin-top: 8px; }
/* ---------- Schmale Bildschirme ---------- */
@media (max-width: 1100px) {
.wk-kpis.stats { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.wk-main { grid-template-columns: minmax(0, 1fr); gap: 24px; }
.wk-side { padding-left: 0; border-left: 0; padding-top: 24px; border-top: 1px solid var(--line); }
}
@media (max-width: 860px) {
/* unter der festen Leiste der Handy-Ansicht (52 px) */
.wk-card { scroll-margin-top: 64px; }
.wk-kpis.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 24px; }
.wk-zoom { gap: 6px; }
.wk-zoom .btn { min-width: 36px; min-height: 36px; }
.wk-toolbar .btn { min-height: 36px; }
/* Pult: Aktionen untereinander, Schaltflächen über die volle Breite */
.wk-pult-row { padding: 12px 0; }
.wk-pult-row .wk-pult-actions { flex-direction: column; align-items: stretch; gap: 8px; }
.wk-pult-row .wk-pult-actions .btn, .wk-pult-row .wk-pult-actions .link-danger { width: 100%; min-height: 40px; justify-content: center; text-align: center; }
/* Felder: Zähler und Knopf untereinander, der Knopf über die volle Breite */
.wk-pult-panel-foot { flex-direction: column; align-items: stretch; }
/* Hauptknopf oben, „Abbrechen“ darunter */
.wk-pult-panel-btns { flex-direction: column-reverse; align-items: stretch; }
.wk-pult-panel-foot .btn { width: 100%; min-height: 40px; justify-content: center; text-align: center; }
/* „Schließen“ im Fuß des Interessenten-Panels: `.dialog-foot .btn` (global.css, 32 px) gilt für alle Dialoge und bleibt; hier nur der eigene Knopf auf 40 px.
Der Dialog hängt nicht unter einer wk-Klasse, die Regel beginnt deshalb mit der Klasse des Knopfs. Mit (0,3,0) (Klasse, Klasse, Attribut) gewinnt sie
gegen (0,2,0), gleich in welcher Reihenfolge die Dateien geladen werden. */
.wk-prospect-close.btn[type="button"] { width: 100%; min-height: 40px; justify-content: center; }
/* Bot-Leads: Blätterknöpfe, „Alle anzeigen“ der Einzelansicht und „Erneut versuchen“ mit 40 px Tippfläche wie die Knöpfe des Pults; min-height schlägt height: 32px von .btn, und mit
zwei Klassen (0,2,0) gegen (0,1,0) braucht es kein !important. Keine globale .btn-Regel: Dialoge und andere Seiten behalten ihre Höhe. Die Zeile darf umbrechen */
.wk-bl-pager .btn { min-height: 40px; }
.wk-bl-single .btn { min-height: 40px; }
.wk-bl-retry .btn { min-height: 40px; }
.wk-bl .table-foot { flex-wrap: wrap; gap: 8px 16px; }
}
/* ---------- Weniger Bewegung ---------- */
@media (prefers-reduced-motion: reduce) {
.wk-list button, .wk-zoom .btn, .wk-toolbar .btn { transition: none; }
}
-179
View File
@@ -1,179 +0,0 @@
// vm-Sandbox für die Szene des Hustler-Hochhauses (Muster: entwuerfe/smoke.js, nur für Tests).
// scene.js wird mit esbuild zu einem IIFE gebündelt und in einem vm-Kontext ohne window/document ausgeführt:
// der Kontext kennt nur console, Math, Date und JSON. Canvas, Zeichenkontext und Bildtakt sind Attrappen.
import vm from 'node:vm'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import esbuild from 'esbuild'
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')
const SCENE = path.join(repoRoot, 'src/components/workers/engine/scene.js')
const STEP_MS = 1000 / 60
// Das Bündel ist für alle Sandboxen gleich: einmal bauen
let bundle = null
function bundleScene() {
bundle ??= esbuild
.build({ entryPoints: [SCENE], bundle: true, format: 'iife', globalName: 'WKEngine', platform: 'neutral', write: false })
.then((result) => result.outputFiles[0].text)
return bundle
}
/**
* createEngineSandbox({ cssWidth = 960, dpr = 1, record = false })
* → { mountTower, engine, canvas, counts, seenTexts, rects, texts, listeners, listenerOptions,
* opts(extra), tick(seconds), jump(seconds), rafPending(), setCssWidth(w),
* click(cssX, cssY), pointer(type, cssX, cssY, extra), wheel(cssX, cssY, deltaY, { ctrlKey }) }
* counts: { fillRect, fillText } zählt nur (es werden keine Aufrufe gespeichert, sonst sprengen tausende Bilder den Speicher)
* seenTexts: Set der verschiedenen gezeichneten Texte
* rects/texts: nur mit record: true, jedes fillRect { n, x, y, w, h, fill, alpha } und jedes fillText { n, text, x, y, font }
* (n = laufende Nummer über beide Listen, damit die Reihenfolge der Aufrufe vergleichbar ist; alpha folgt save/restore)
* listeners: Typ -> Liste der Rückrufe am Canvas; listenerOptions: Map Rückruf -> Optionen von addEventListener
* tick(s): rückt die Uhr in Schritten von 1000/60 ms vor und führt fällige raf-Rückrufe aus; jump(s) rückt die Uhr ohne Rückrufe
* (ein Stillstand); click/pointer/wheel schicken Ereignisse in CSS-Pixeln relativ zur Leinwand (Rand 0, Breite cssWidth, Höhe 4/5)
*/
export async function createEngineSandbox({ cssWidth = 960, dpr = 1, record = false } = {}) {
const code = await bundleScene()
const counts = { fillRect: 0, fillText: 0 }
const seenTexts = new Set()
const rects = []
const texts = []
const listeners = {}
const listenerOptions = new Map()
const alphaStack = []
let seq = 0
let width = cssWidth
const target = { fillStyle: '#000000', font: '10px sans-serif', globalAlpha: 1, textAlign: 'left', textBaseline: 'alphabetic' }
const ctx = new Proxy(target, {
get(t, prop) {
if (typeof prop === 'symbol') return undefined
if (prop === 'save') return () => alphaStack.push(t.globalAlpha)
if (prop === 'restore') return () => { if (alphaStack.length > 0) t.globalAlpha = alphaStack.pop() }
if (prop === 'measureText') return (text) => ({ width: String(text).length * 7 })
if (prop === 'fillRect') {
return (x, y, w, h) => {
counts.fillRect += 1
if (record) rects.push({ n: seq++, x, y, w, h, fill: t.fillStyle, alpha: t.globalAlpha })
}
}
if (prop === 'fillText') {
return (text, x, y) => {
if (typeof text !== 'string') throw new Error('fillText mit Nicht-String: ' + text)
counts.fillText += 1
seenTexts.add(text)
if (record) texts.push({ n: seq++, text, x, y, font: t.font })
}
}
if (prop === 'createLinearGradient' || prop === 'createRadialGradient') return () => ({ addColorStop() {} })
if (prop === 'getImageData') return () => ({ data: new Uint8ClampedArray(4) })
if (prop in t) return t[prop]
return () => {}
},
set(t, prop, value) {
t[prop] = value
return true
},
})
const canvas = {
width: 300,
height: 150,
style: {},
getContext: () => ctx,
addEventListener(type, fn, options) {
listeners[type] = [...(listeners[type] ?? []), fn]
if (options !== undefined) listenerOptions.set(fn, options)
},
removeEventListener(type, fn) {
listeners[type] = (listeners[type] ?? []).filter((f) => f !== fn)
},
getBoundingClientRect: () => ({ left: 0, top: 0, width, height: width * 0.8 }),
}
target.canvas = canvas
// Bildtakt: raf/caf führen Buch, now ist die Uhr der Sandbox
let clock = 0
let nextId = 0
const queue = new Map()
const raf = (cb) => {
nextId += 1
queue.set(nextId, cb)
return nextId
}
const caf = (id) => {
queue.delete(id)
}
const now = () => clock
function tick(seconds) {
const steps = Math.round(seconds * 60)
for (let i = 0; i < steps; i++) {
clock += STEP_MS
for (const id of [...queue.keys()]) {
const cb = queue.get(id)
if (!cb) continue
queue.delete(id)
cb(clock)
}
}
}
const sandbox = { console, Math, Date, JSON }
vm.createContext(sandbox)
vm.runInContext(code, sandbox, { filename: 'scene.bundle.js' })
const engine = sandbox.WKEngine
const fire = (type, event) => {
for (const fn of [...(listeners[type] ?? [])]) fn(event)
}
const makeEvent = (x, y, extra = {}) => ({
clientX: x,
clientY: y,
button: 0,
pointerId: 1,
ctrlKey: false,
deltaY: 0,
deltaMode: 0,
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true
},
stopPropagation() {},
...extra,
})
return {
mountTower: engine.mountTower,
engine,
canvas,
counts,
seenTexts,
rects,
texts,
listeners,
listenerOptions,
opts: (extra = {}) => ({ raf, caf, now, dpr, ...extra }),
tick,
jump(seconds) {
clock += seconds * 1000
},
rafPending: () => queue.size,
setCssWidth(w) {
width = w
},
click(cssX, cssY) {
fire('click', makeEvent(cssX, cssY))
},
pointer(type, cssX, cssY, extra = {}) {
fire(type, makeEvent(cssX, cssY, extra))
},
wheel(cssX, cssY, deltaY, { ctrlKey = false } = {}) {
const event = makeEvent(cssX, cssY, { deltaY, ctrlKey })
fire('wheel', event)
return event
},
}
}
-51
View File
@@ -1,51 +0,0 @@
// Aufzeichnender Canvas-Kontext für die Engine-Tests (Muster: Proxy aus entwuerfe/smoke.js).
// fillRect/fillText werden mit dem aktuellen fillStyle/font notiert, alles Übrige ist ein No-op.
/**
* makeCtx({ width, height }) → { ctx, rects, texts, strokes, counts, alphas }
* rects: [{ x, y, w, h, fill }] jedes fillRect
* texts: [{ text, x, y, font }] jedes fillText (Nicht-String wirft wie im Smoke-Test)
* strokes: [{ text, x, y, style, width, rects, texts }] jedes strokeText (style/width = strokeStyle/lineWidth, rects/texts =
* Anzahl der bis dahin gezeichneten fillRect/fillText: so ist die Reihenfolge prüfbar)
* counts: { save, restore } Aufrufe von ctx.save()/ctx.restore()
* alphas: [] jede Zuweisung an ctx.globalAlpha
*/
export function makeCtx({ width = 960, height = 768 } = {}) {
const rects = []
const texts = []
const strokes = []
const counts = { save: 0, restore: 0 }
const alphas = []
const target = { canvas: { width, height }, fillStyle: '#000000', font: '10px sans-serif', globalAlpha: 1 }
const ctx = new Proxy(target, {
get(t, prop) {
if (typeof prop === 'symbol') return undefined
if (prop === 'save') return () => { counts.save++ }
if (prop === 'restore') return () => { counts.restore++ }
if (prop === 'measureText') return (text) => ({ width: String(text).length * 7 })
if (prop === 'fillRect') return (x, y, w, h) => { rects.push({ x, y, w, h, fill: t.fillStyle }) }
if (prop === 'fillText') {
return (text, x, y) => {
if (typeof text !== 'string') throw new Error('fillText mit Nicht-String: ' + text)
texts.push({ text, x, y, font: t.font })
}
}
if (prop === 'strokeText') {
return (text, x, y) => {
if (typeof text !== 'string') throw new Error('strokeText mit Nicht-String: ' + text)
strokes.push({ text, x, y, style: t.strokeStyle, width: t.lineWidth, rects: rects.length, texts: texts.length })
}
}
if (prop === 'createLinearGradient' || prop === 'createRadialGradient') return () => ({ addColorStop() {} })
if (prop === 'getImageData') return () => ({ data: new Uint8ClampedArray(4) })
if (prop in t) return t[prop]
return () => {}
},
set(t, prop, value) {
if (prop === 'globalAlpha') alphas.push(value)
t[prop] = value
return true
},
})
return { ctx, rects, texts, strokes, counts, alphas }
}
+2 -1
View File
@@ -214,7 +214,8 @@ test('schrift.css bindet Press Start 2P selbst gehostet ein (Datei vorhanden, OF
// lateinischer Schnitt aus @fontsource/press-start-2p@5.2.7 (files/press-start-2p-latin-400-normal.woff2): Ziffern, Umlaute, ß, € (kein U+2212)
assert.equal(createHash('sha256').update(readFileSync(datei)).digest('hex'), 'afec86997fdaf54af1f59358fa2c1e2a0f1d04146edad18e5cd141d0384a7548')
assert.ok(!/googleapis|gstatic/.test(schrift))
assert.ok(!/Pixelify/i.test(schrift), 'Pixelify Sans ist schon in workers.css eingebunden, nicht doppelt')
// Seit T11 steht auch Pixelify Sans hier (nicht mehr in workers.css): genau eine Einbindung; Datei und Lizenz prüft workersFont.test.mjs
assert.equal((schrift.match(/font-family:\s*["']Pixelify Sans["']/g) ?? []).length, 1, 'Pixelify Sans genau einmal')
const lizenz = lies(path.join(REPO, 'public/fonts/OFL-PressStart2P.txt'))
assert.match(lizenz, /^Copyright 2012 The Press Start 2P Project Authors \(cody@zone38\.net\), with Reserved Font Name "Press Start 2P"\.?$/m)
assert.match(lizenz, /SIL OPEN FONT LICENSE Version 1\.1/)
+13
View File
@@ -621,6 +621,19 @@ test('Komponente: kein innerHTML, kein CSS- oder Bild-Import, Links nur über sa
}
})
// Übernommen aus der Prüfung der früheren Worker-Karte (Review Focus 5, PF-08): Ziele nur aus dem safeLink-Ergebnis, extern mit noopener, jeder Knopf mit type.
test('Komponente: jedes Link-Ziel (href, to) kommt aus dem safeLink-Ergebnis, externe Links mit rel noopener, jeder Knopf type="button"', () => {
const quelle = fs.readFileSync(path.join(ROOT, 'src/components/workers/hochhaus/HustlerDialog.jsx'), 'utf8')
const src = quelle.replace(/(^|\s)\/\/.*$/gm, '$1')
assert.match(src, /safeLink\(/)
assert.match(src, /<a href=\{safe\.href\} target="_blank" rel="noopener noreferrer">/); assert.match(src, /<Link to=\{safe\.href\}>/)
assert.doesNotMatch(src, /\b(?:href|to)=\{(?!safe\.href\})/) // kein anderes Ziel, nie aus dem Ereignis selbst
assert.equal((src.match(/<a\b/g) || []).length, 1); assert.equal((src.match(/<Link\b/g) || []).length, 1)
const knoepfe = (src.match(/<button\b/g) || []).length
assert.ok(knoepfe >= 4, 'Hintergrund, Schließen, ◂ und ▸')
assert.equal((src.match(/<button type="button"/g) || []).length, knoepfe)
})
test('hustler-detail.css: Kopfkommentar, alle Regeln unter .hk-hochhaus, alle Klassen mit hk-, Handy-Regeln unter 720 px', () => {
const css = fs.readFileSync(path.join(ROOT, 'src/styles/hustler/hustler-detail.css'), 'utf8')
assert.ok(css.trimStart().startsWith('/*'), 'Kopfkommentar')
+24 -1
View File
@@ -66,7 +66,8 @@ async function hochhaus(feed, extra = {}) {
const props = { api: { name: 'api' }, isAdmin: false, workers: {}, regieRef: { current: null }, onOpen() {}, ...extra, feed }
const mounted = __mount(jsx(() => jsx(Hochhaus, props), {}))
await settle()
return { props, hh: globalThis.__hh, tree: () => mounted.tree(), end: () => mounted.unmount() }
// setFeed: der Feed ändert sich zwischendurch (wie der Hook der Seite), das Hochhaus bleibt am selben Einhängepunkt
return { props, hh: globalThis.__hh, tree: () => mounted.tree(), setFeed(next) { props.feed = { ...props.feed, ...next }; mounted.rerender() }, end: () => mounted.unmount() }
}
const counting = (n = { refresh: 0 }) => ({ n, refresh: () => { n.refresh += 1 } })
const teil = (tree, name) => nodes(tree, (n) => n.props && n.props['data-teil'] === name)
@@ -174,6 +175,28 @@ test('Hochhaus: ohne Zustand gilt auch ein geladen-Flag nicht als bereit (wie bi
} finally { view.end() }
})
// Frühere Prüfung des Boards (Hooks vor der frühen Rückgabe): Laden, Leerzustand und Welt wechseln am selben Einhängepunkt, die Auswahl überlebt Verbindungsfehler.
test('Hochhaus: Laden -> Dienst nicht erreicht -> bereit -> Verbindungsfehler -> wieder gut, ohne Neueinhängen; die offene Detailansicht überlebt den Fehler', async () => {
const c = counting()
const view = await hochhaus({ state: null, error: null, loaded: false, refresh: c.refresh })
try {
assert.equal(byClass(view.tree(), 'hk-laden').length, 1)
view.setFeed({ error: 'weg' })
assert.equal(byClass(view.tree(), 'hk-leer').length, 1); assert.equal(byClass(view.tree(), 'hk-laden').length, 0)
view.setFeed({ state: makeState(), error: null, loaded: true })
assert.equal(teil(view.tree(), 'welt').length, 1); assert.equal(byClass(view.tree(), 'hk-leer').length, 0)
view.hh.welt.onZeige('lea')
await settle()
assert.equal(teil(view.tree(), 'dialog').length, 1)
view.setFeed({ error: 'Verbindung weg' })
assert.equal(teil(view.tree(), 'welt').length, 1); assert.equal(teil(view.tree(), 'dialog').length, 1)
assert.equal(view.hh.dialog.workerId, 'lea'); assert.equal(view.hh.welt.gesperrt, true)
view.setFeed({ error: null })
assert.equal(teil(view.tree(), 'welt').length, 1); assert.equal(view.hh.dialog.workerId, 'lea')
assert.equal(c.n.refresh, 0)
} finally { view.end() }
})
// ---------- WorkersPage ----------
// Attrappen: alle Zustände und Aufrufe laufen über globalThis.__hk, das der Test je Lauf anlegt
-119
View File
@@ -1,119 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// Kamera: Zoom, Verschieben, Umkehr-Transformation, Figuren-Treffer (Ansicht = Welt/Zoom, Bruchteile 0..1)
const C = await loadModule('src/components/workers/engine/camera.js')
const close = (a, b) => Math.abs(a - b) < 1e-9
test('fitZoom: ab 560 px 1, darunter wachsend bis 2,2', () => {
assert.equal(C.fitZoom(1280), 1)
assert.equal(C.fitZoom(600), 1)
assert.equal(C.fitZoom(560), 1)
assert.ok(close(C.fitZoom(390), 560 / 390))
assert.equal(C.fitZoom(200), 2.2)
})
test('fitZoom: ungültige Breite gibt 1, nie NaN', () => {
for (const w of [0, -5, NaN, undefined, null, Infinity]) assert.equal(C.fitZoom(w), 1, String(w))
})
test('Zoomgrenzen und Startkamera', () => {
assert.deepEqual([C.ZOOM_MIN, C.ZOOM_MAX], [1, 3])
assert.deepEqual(C.createCamera(), { zoom: 1, x: 0, y: 0 })
assert.notEqual(C.createCamera(), C.createCamera())
})
test('zoomAt hält den Punkt unter dem Zeiger fest, klemmt Zoom und Rand', () => {
const p0 = C.viewToWorld(C.createCamera(), 0.25, 0.75)
const cam = C.zoomAt(C.createCamera(), 2, 0.25, 0.75)
assert.equal(cam.zoom, 2)
const p1 = C.viewToWorld(cam, 0.25, 0.75)
assert.ok(Math.abs(p1.x - p0.x) < 1e-9 && Math.abs(p1.y - p0.y) < 1e-9)
assert.equal(C.zoomAt(C.createCamera(), 10, 0.5, 0.5).zoom, 3)
assert.equal(C.zoomAt(C.createCamera(), 0.1, 0.5, 0.5).zoom, 1)
const edge = C.panBy({ zoom: 2, x: 0, y: 0 }, 5, 5)
assert.deepEqual([edge.x, edge.y], [160, 128])
const back = C.panBy({ zoom: 2, x: 10, y: 10 }, -5, -5)
assert.deepEqual([back.x, back.y], [0, 0])
})
test('viewToWorld und worldToView sind zueinander invers; centerOn', () => {
const cam = { zoom: 2.5, x: 40, y: 60 }
const w = C.viewToWorld(cam, 0.3, 0.6)
const v = C.worldToView(cam, w.x, w.y)
assert.ok(Math.abs(v.vx - 0.3) < 1e-9 && Math.abs(v.vy - 0.6) < 1e-9)
const c = C.centerOn({ zoom: 2, x: 0, y: 0 }, 160, 128)
assert.deepEqual([c.x, c.y], [80, 64])
})
test('pickAgent: oberste Figur gewinnt, Polster vergrößert die Fläche, Treffer nur im Kasten', () => {
const boxes = [{ id: 'a', box: { x: 10, y: 10, w: 12, h: 16 } }, { id: 'b', box: { x: 16, y: 10, w: 12, h: 16 } }]
assert.equal(C.pickAgent(boxes, 18, 14), 'b')
assert.equal(C.pickAgent(boxes, 11, 14), 'a')
assert.equal(C.pickAgent(boxes, 40, 14), null)
assert.equal(C.pickAgent(boxes, 30, 14, 0), null)
assert.equal(C.pickAgent(boxes, 30, 14, 3), 'b')
})
// ---- Ergänzungen: Verhalten, das die Tests oben nicht berühren ----
test('Sichtfeld ist Welt/Zoom: bei Zoom 1 gibt es nichts zu verschieben', () => {
const cam = C.panBy(C.createCamera(), 0.4, -0.3)
assert.deepEqual([cam.zoom, cam.x, cam.y], [1, 0, 0])
assert.deepEqual(C.viewToWorld(C.createCamera(), 1, 1), { x: 320, y: 256 })
assert.deepEqual(C.viewToWorld({ zoom: 2, x: 160, y: 128 }, 1, 1), { x: 320, y: 256 })
})
test('panBy verschiebt in Bruchteilen der Ansicht und klemmt am Rand', () => {
const cam = C.panBy({ zoom: 2, x: 40, y: 30 }, 0.25, 0.5)
assert.deepEqual([cam.x, cam.y], [80, 94])
})
test('zoomAt zurück auf 1 setzt die Kamera auf den Ursprung zurück (Rand wird geklemmt)', () => {
const cam = C.zoomAt({ zoom: 3, x: 100, y: 80 }, 0.01, 0.5, 0.5)
assert.deepEqual([cam.zoom, cam.x, cam.y], [1, 0, 0])
})
test('zoomAt am Rand: der Punkt unter dem Zeiger bleibt, solange der Rand nicht klemmt', () => {
const start = { zoom: 2, x: 80, y: 64 }
const p0 = C.viewToWorld(start, 0.6, 0.4)
const cam = C.zoomAt({ ...start }, 1.5, 0.6, 0.4)
assert.equal(cam.zoom, 3)
const p1 = C.viewToWorld(cam, 0.6, 0.4)
assert.ok(close(p1.x, p0.x) && close(p1.y, p0.y))
})
test('clampCamera klemmt Zoom und Lage, auch mit eigener Welt; Aufrufer bekommt dieselbe Kamera zurück', () => {
const cam = { zoom: 5, x: -10, y: 900 }
assert.equal(C.clampCamera(cam), cam)
assert.deepEqual([cam.zoom, cam.x, cam.y], [3, 0, 256 - 256 / 3])
const klein = C.clampCamera({ zoom: 2, x: 99, y: 99 }, { w: 100, h: 50 })
assert.deepEqual([klein.x, klein.y], [50, 25])
})
test('centerOn klemmt in den Ecken', () => {
const c = C.centerOn({ zoom: 2, x: 80, y: 64 }, 0, 0)
assert.deepEqual([c.x, c.y], [0, 0])
const d = C.centerOn({ zoom: 2, x: 0, y: 0 }, 320, 256)
assert.deepEqual([d.x, d.y], [160, 128])
})
test('ungültige Eingaben (NaN, Infinity) machen die Kamera nie kaputt', () => {
const a = C.zoomAt({ zoom: 2, x: 20, y: 10 }, NaN, 0.5, 0.5)
assert.deepEqual([a.zoom, a.x, a.y], [2, 20, 10])
const b = C.panBy({ zoom: 2, x: 20, y: 10 }, NaN, Infinity)
assert.ok(Number.isFinite(b.x) && Number.isFinite(b.y))
assert.equal(b.x, 20)
const c = C.clampCamera({ zoom: NaN, x: NaN, y: NaN })
assert.deepEqual([c.zoom, c.x, c.y], [1, 0, 0])
})
test('pickAgent: leere Liste, kaputte Einträge und Rand des Kastens', () => {
assert.equal(C.pickAgent([], 5, 5), null)
assert.equal(C.pickAgent(undefined, 5, 5), null)
const boxes = [{ id: 'a', box: { x: 10, y: 10, w: 12, h: 16 } }, { id: 'kaputt' }, null]
assert.equal(C.pickAgent(boxes, 22, 26), 'a')
assert.equal(C.pickAgent(boxes, 23, 26), null)
assert.equal(C.pickAgent(boxes, 23, 26, 1), 'a')
})
+132 -116
View File
@@ -1,29 +1,141 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Die React-Teile der Hustler-Seite sind unter Node nicht ladbar (JSX, Canvas, Router): geprüft wird der Quelltext
// (Muster adminRoute.test.mjs). Pfade relativ zu src/components/workers/.
const read = (p) => readFileSync(new URL('../../src/components/workers/' + p, import.meta.url), 'utf8')
// Regeln für alle React-Teile der Hustler-Seite, geprüft am Quelltext (Muster adminRoute.test.mjs): src/components/workers/** mit hochhaus/, pult/ und keller/.
// Das Verhalten der einzelnen Bildschirme prüfen die Komponententests (hustlerKopf, hustlerDialog, hustlerHochhausWelt, hustlerSeite, hustlerPult, hustlerKeller ...).
// Seit T11 gibt es die alte helle Oberfläche und die Kanvas-Engine nicht mehr; ihre Zusicherungen sind in diese Tests gewandert (Tabelle im Übergabebericht von T11).
const COMPONENTS = fileURLToPath(new URL('../../src/components/workers/', import.meta.url))
const SRC = fileURLToPath(new URL('../../src/', import.meta.url))
const read = (p) => readFileSync(path.join(COMPONENTS, p), 'utf8')
const readSrc = (p) => readFileSync(path.join(SRC, p), 'utf8')
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]))
const komponenten = () => walk(COMPONENTS).filter((f) => /\.(jsx?|mjs)$/.test(f))
const rel = (f) => path.relative(COMPONENTS, f)
// Kommentare weg: Block- und Zeilenkommentare (// nur am Zeilenanfang oder nach Leerraum, damit https:// in Zeichenketten bleibt)
const ohneKommentare = (code) => code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
test('WorkerCard: Links nur über safeLink, extern mit rel noopener; deutsche Beschriftungen (Review Focus 5)', () => {
const src = read('WorkerCard.jsx')
assert.match(src, /safeLink\(/); assert.match(src, /rel="noopener noreferrer"/); assert.match(src, /target="_blank"/)
for (const t of ['Macht gerade', 'Tokens heute', 'Kosten heute', 'Letzte Aktionen']) assert.ok(src.includes(t), t)
assert.match(src, /MODEL_IDS/); assert.match(src, /wk-chip-/)
// jeder href und jedes to kommt aus dem safeLink-Ergebnis, nie aus dem Ereignis selbst
assert.match(src, /href=\{safe\.href\}/); assert.match(src, /to=\{safe\.href\}/)
assert.doesNotMatch(src, /\b(?:href|to)=\{(?!safe\.href\})/)
// Öffnende Tags `<name …>` samt Attributen. Klammern {…} und Zeichenketten werden übersprungen, damit das `=>` eines onClick das Tag nicht beendet.
function oeffnendeTags(src, name) {
const tags = []
const re = new RegExp(`<${name}(?=[\\s/>])`, 'g')
for (let m; (m = re.exec(src)) !== null;) {
let tiefe = 0
let quote = ''
let i = m.index + m[0].length
for (; i < src.length; i++) {
const c = src[i]
if (quote) {
if (c === '\\') i += 1
else if (c === quote) quote = ''
continue
}
if (c === '"' || c === "'" || c === '`') quote = c
else if (c === '{') tiefe += 1
else if (c === '}') tiefe -= 1
else if (c === '>' && tiefe === 0) break
}
tags.push(src.slice(m.index, i + 1))
}
return tags
}
// Wert eines Attributs im Tag: Zeichenkette (mit Anführungszeichen) oder der Ausdruck in {…} ohne Klammern; null, wenn es das Attribut nicht gibt
function attribut(tag, name) {
const m = new RegExp(`(?<![\\w-])${name}=`).exec(tag)
if (!m) return null
const start = m.index + m[0].length
if (tag[start] === '"') return tag.slice(start, tag.indexOf('"', start + 1) + 1)
if (tag[start] !== '{') return null
let tiefe = 0
for (let i = start; i < tag.length; i++) {
if (tag[i] === '{') tiefe += 1
else if (tag[i] === '}') {
tiefe -= 1
if (tiefe === 0) return tag.slice(start + 1, i).trim()
}
}
return null
}
test('kein innerHTML und keine Kanvas-Zeichnung in den Komponenten unter src/components/workers/** (rekursiv, Kommentare ignoriert)', () => {
const dateien = komponenten()
for (const dir of ['hochhaus', 'pult', 'keller']) assert.ok(dateien.some((f) => rel(f).startsWith(dir + path.sep)), `${dir}/ wird mitgelesen`)
assert.ok(dateien.length >= 15, `nur ${dateien.length} Dateien gelesen`)
for (const f of dateien) {
const code = ohneKommentare(readFileSync(f, 'utf8'))
assert.doesNotMatch(code, /dangerouslySetInnerHTML|innerHTML|outerHTML|insertAdjacentHTML|document\.write/, rel(f))
assert.doesNotMatch(code, /<canvas\b|getContext\s*\(/, `${rel(f)}: die Pixelwelt ist DOM + CSS, kein Kanvas`)
}
})
test('kein innerHTML in Komponenten und Engine', () => {
for (const dir of ['', 'engine/']) for (const f of readdirSync(new URL('../../src/components/workers/' + dir, import.meta.url))) { if (!/\.(jsx|js)$/.test(f)) continue; const s = read(dir + f); assert.doesNotMatch(s, /dangerouslySetInnerHTML|innerHTML/, dir + f) }
// Frühere Prüfung des Boards: Rechte und Nutzer kommen als Props von der Seite (isAdmin, me), die Bildschirme greifen nie selbst auf Anmeldung oder Appwrite zu.
test('Komponenten ohne Anmeldungsbezug: kein useAuth und kein appwrite unter src/components/workers/**', () => {
for (const f of komponenten()) assert.doesNotMatch(ohneKommentare(readFileSync(f, 'utf8')), /useAuth|appwrite/i, rel(f))
})
test('WorkersKpis nutzt Stat und kpiTiles; WorkerList: Knopf je Worker mit data-wk-worker, aria-pressed und Statustext', () => {
assert.match(read('WorkersKpis.jsx'), /kpiTiles\(/); assert.match(read('WorkersKpis.jsx'), /<Stat /)
const l = read('WorkerList.jsx'); assert.match(l, /data-wk-worker=/); assert.match(l, /aria-pressed=/); assert.match(l, /aria-label=/); assert.match(l, /FLOOR_TITLES/)
// die Liste enthält keine Links: ein Klick wählt nur aus
assert.doesNotMatch(l, /<a[\s>]|<Link[\s>]|href=/)
test('die alte Oberfläche und die Kanvas-Engine sind weg, und nichts unter src/ verweist noch darauf (T11)', () => {
for (const alt of ['engine', 'WorkersStage.jsx', 'WorkersBoard.jsx', 'WorkerCard.jsx', 'WorkerList.jsx', 'WorkersKpis.jsx']) assert.equal(existsSync(path.join(COMPONENTS, alt)), false, alt)
const verweis = /['"][^'"\n]*(?:\/engine\/[\w.-]+|\/(?:WorkersStage|WorkersBoard|WorkerCard|WorkerList|WorkersKpis))(?:\.jsx?)?['"]/
let gelesen = 0
for (const f of walk(SRC).filter((x) => /\.jsx?$/.test(x))) {
gelesen += 1
assert.doesNotMatch(ohneKommentare(readFileSync(f, 'utf8')), verweis, path.relative(SRC, f))
}
assert.ok(gelesen > 100, `nur ${gelesen} Dateien gelesen`)
})
// Frühere Prüfung der Bühne und des Boards (PF-08): der WOMS-Button hat keinen Standard-type, ein <button> ohne type schickt in einem Formular ab.
test('jeder Knopf (<button>, <Button>) hat ein ausdrückliches type="button"; „submit“ gibt es nur im Formular der Einstellungen', () => {
let anzahl = 0
for (const f of komponenten().filter((x) => x.endsWith('.jsx'))) {
const code = ohneKommentare(readFileSync(f, 'utf8'))
for (const name of ['button', 'Button']) {
const tags = oeffnendeTags(code, name)
assert.equal(tags.length, (code.match(new RegExp(`<${name}(?=[\\s/>])`, 'g')) || []).length, `${rel(f)}: ${name}-Tags vollständig gelesen`)
for (const tag of tags) {
anzahl += 1
const erlaubt = rel(f) === 'WorkersSettingsDialog.jsx' ? /\btype="(?:button|submit)"/ : /\btype="button"/
assert.match(tag, erlaubt, `${rel(f)}: ${tag.replace(/\s+/g, ' ').slice(0, 90)}`)
}
}
}
assert.ok(anzahl >= 35, `nur ${anzahl} Knöpfe gelesen`)
// „submit“ kommt genau einmal vor: „Speichern“ im Formular
assert.equal((ohneKommentare(read('WorkersSettingsDialog.jsx')).match(/type="submit"/g) || []).length, 1)
})
// Frühere Prüfung der Worker-Karte (Review Focus 5): Ziele von Links kommen nur aus geprüften Feldern, externe öffnen mit noopener.
test('Links: jedes Ziel (href, to) kommt aus einem geprüften Feld oder ist fest; externe Ziele öffnen im neuen Tab mit rel="noopener noreferrer"', () => {
// safeLink (Spur im Dialog) · sourceHref/previewHref (workersPult, botleadsView, workersProspect über safeExternalUrl/safeHref/previewUrl) · mailtoHref · feste Pfade der Etagen
const ERLAUBT = new Set(['safe.href', 'row.sourceHref', 'row.previewHref', 'view.sourceHref', 'e.sourceHref', 'mail', '`/hustler?tab=${link.tab}`'])
let links = 0
for (const f of komponenten().filter((x) => x.endsWith('.jsx'))) {
const code = ohneKommentare(readFileSync(f, 'utf8'))
for (const [name, ziel] of [['a', 'href'], ['Link', 'to']]) {
for (const tag of oeffnendeTags(code, name)) {
links += 1
const wert = attribut(tag, ziel)
assert.ok(wert !== null, `${rel(f)}: ${name} ohne ${ziel}: ${tag.slice(0, 80)}`)
assert.ok(wert.startsWith('"') || ERLAUBT.has(wert), `${rel(f)}: ${ziel}={${wert}} ist kein geprüftes Ziel`)
if (attribut(tag, 'target') === '"_blank"') assert.equal(attribut(tag, 'rel'), '"noopener noreferrer"', `${rel(f)}: ${tag.slice(0, 80)}`)
if (wert.startsWith('"')) assert.doesNotMatch(wert, /javascript:|data:/i)
}
}
}
assert.ok(links >= 8, `nur ${links} Links gelesen`)
// die Felder selbst entstehen in den Ansichten nur über die Prüfer
assert.match(readSrc('lib/workersPult.js'), /sourceHref: freigabe \? null : safeExternalUrl\(item\.sourceUrl\)/)
assert.match(readSrc('lib/workersPult.js'), /previewHref: freigabe \? previewUrl\(item\.sourceUrl\) : null/)
assert.match(readSrc('lib/workersProspect.js'), /sourceHref: safeExternalUrl\(prospect\.sourceUrl\)/)
assert.match(readSrc('lib/botleadsView.js'), /sourceHref: safeHref\(it\.sourceUrl\)/)
assert.match(readSrc('lib/workersPult.js'), /return `mailto:\$\{to\}/)
// die Etagen-Links des Hochhauses sind feste Pfade
assert.match(read('hochhaus/HochhausWelt.jsx'), /tab: 'freigabe'[\s\S]*tab: 'botleads'/)
})
test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Beschriftungen, Notbremsen-Block', () => {
const s = read('WorkersSettingsDialog.jsx')
assert.match(s, /from '\.\.\/ui\/Dialog'/); assert.match(s, /formToPatch\(/); assert.match(s, /validateForm\(/); assert.match(s, /api\.saveSettings\(/)
@@ -35,57 +147,8 @@ test('Einstellungs-Dialog: Dialog-Baustein, Formularlogik, kein paused, Admin-Be
assert.match(s, /api\.saveSettings\(\{ sendingHalted: false \}\)/); assert.match(s, /sendingHaltedReason/)
assert.doesNotMatch(s, /formToPatch\([^)]*sendingHalted/)
})
test('WorkersStage: Aufräumen, Sichtbarkeit, Bewegungsreduktion', () => {
const s = read('WorkersStage.jsx')
assert.match(s, /\.destroy\(\)/); assert.match(s, /visibilitychange/); assert.match(s, /removeEventListener\('visibilitychange'/); assert.match(s, /prefers-reduced-motion: reduce/)
assert.match(s, /ResizeObserver/); assert.match(s, /disconnect\(\)/); assert.match(s, /fontsReady/); assert.match(s, /forwardRef/)
// Controller-Beschluss: Sichtbarkeit steuert die Szene (setPaused), beim Zurückkehren wird die Regie beruhigt (flush)
assert.match(s, /setPaused\(/); assert.match(s, /\.flush\(\)/); assert.match(s, /mountTower\(/); assert.match(s, /engineKey/)
assert.match(s, /className="wk-stage"/); assert.match(s, /className="wk-canvas"/); assert.match(s, /className="wk-zoom"/)
assert.ok(s.includes('Pixel-Hochhaus: Etagen und Räume der Hustler. Auswahl auch über die Liste.'))
for (const t of ['+', '−', 'Ganz']) assert.ok(s.includes(`>${t}<`), t)
// jeder Knopf der Bühne ist ein type="button" (WOMS-Button hat keinen Standard-type, PF-08)
assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length)
})
test('WorkersBoard: Zustände und Rechte (Review Focus 1)', () => {
const s = read('WorkersBoard.jsx')
assert.match(s, /Der Hustler-Dienst ist noch nicht erreichbar/); assert.match(s, /Verbindung gestört/); assert.match(s, /reduceEvents\(/); assert.match(s, /pauseLabel\(/)
assert.match(s, /isAdmin &&/); assert.match(s, /stageRef\s*(\?\?|\|\|)/); assert.match(s, /useRef\(/); assert.doesNotMatch(s, /useAuth|appwrite/)
// H4/A21: Notbremsen-Zeile für alle, Grund als Textknoten
assert.match(s, /state\.sendingHalted/); assert.match(s, /state\.sendingHaltedReason/); assert.match(s, /className="msg err"/); assert.match(s, /role="alert"/)
assert.ok(s.includes('Versand gestoppt: ')); assert.ok(s.includes('Ein Admin kann ihn in den Einstellungen freigeben.')); assert.ok(s.includes('Grund nicht angegeben'))
assert.match(s, /onSaved=\{/); assert.doesNotMatch(s, /dangerouslySetInnerHTML|innerHTML/)
// Controller-Beschluss T19: Tabelle nach workerId an Karte und Liste, Karte bekommt Eintrag und Spur, Zustand kommt aus indexWorkers
assert.match(s, /indexWorkers\(/); assert.match(s, /useWorkersFeed\(/); assert.match(s, /useWorkerTrace\(/); assert.match(s, /api\.pause\(/)
assert.match(s, /<WorkerCard\b[^>]*\bworker=\{/); assert.match(s, /<WorkerCard\b[^>]*\btrace=\{/); assert.match(s, /<WorkerList\b[^>]*\bworkers=\{workers\}/); assert.match(s, /<WorkersKpis\b/)
// Links entstehen nur in der Karte über safeLink, das Board hat keine
assert.doesNotMatch(s, /href=|<a[\s>]|<Link[\s>]/)
assert.match(s, /className="wk-toolbar"/); assert.match(s, /className="wk-main"/); assert.match(s, /className="loading"/)
assert.ok(s.includes('Angehalten: Es wird nichts gesendet und nichts gebaut.')); assert.ok(s.includes('Neu laden')); assert.ok(s.includes('Grund nicht angegeben'))
// PF-08: jeder Knopf ein type="button" (WOMS-Button hat keinen Standard-type)
assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length)
})
test('Karte und Board: Auswahl setzt den Fokus auf den Namen und holt die Karte ins Bild, Schließen gibt den Fokus zurück (Endprüfung P2, Handy/Tablet)', () => {
const card = read('WorkerCard.jsx')
// Namen fokussierbar machen (tabIndex -1), Fokus ohne eigenes Springen, danach gezielt an den oberen Rand holen, wenn der Kartenanfang nicht im Fenster liegt
assert.match(card, /<h2 className="wk-name" ref=\{headingRef\} tabIndex=\{-1\}>/)
assert.match(card, /ref=\{sectionRef\}/)
assert.match(card, /heading\.focus\(\{ preventScroll: true \}\)/)
assert.match(card, /section\.scrollIntoView\(\{ block: 'start' \}\)/)
assert.match(card, /scrollMarginTop/)
assert.match(card, /\}, \[workerId\]\)/)
// Hooks stehen vor der frühen Rückgabe ohne Auswahl (Regeln der Hooks)
assert.ok(card.indexOf('useEffect(() => {\n if (!workerId) return') > 0)
assert.ok(card.indexOf('useEffect(() => {\n if (!workerId) return') < card.indexOf(' if (!workerId) {\n return ('))
const board = read('WorkersBoard.jsx')
assert.match(board, /const sideRef = useRef\(null\)/); assert.match(board, /<div className="wk-side" ref=\{sideRef\}>/)
assert.match(board, /onClose=\{closeCard\}/); assert.match(board, /data-wk-worker/); assert.match(board, /button\.focus\(\)/)
assert.doesNotMatch(board, /onClose=\{\(\) => setSelectedId\(null\)\}/)
})
// ---- P3-T21: Abmelden setzt das JWT zurück, „Alles anhalten“ fragt vorher nach ----
const readSrc = (p) => readFileSync(new URL('../../src/' + p, import.meta.url), 'utf8')
// ---- P3-T21: Abmelden setzt das JWT zurück ----
test('Abmelden: AuthContext ruft resetWorkersJwt() auf, workersClient setzt JWT-Cache und Sperre zurück (P3-T21)', () => {
const auth = readSrc('context/AuthContext.jsx')
@@ -101,50 +164,3 @@ test('Abmelden: AuthContext ruft resetWorkersJwt() auf, workersClient setzt JWT-
const fn = client.slice(client.indexOf('export function resetWorkersJwt()'))
assert.match(fn, /jwtCache\.invalidate\(\)/); assert.match(fn, /workersApi\.resetAuth\(\)/)
})
test('WorkersBoard: „Alles anhalten“ öffnet erst einen Bestätigungsdialog, „Fortsetzen“ geht sofort (P3-T21)', () => {
const s = read('WorkersBoard.jsx')
assert.match(s, /import Dialog from '\.\.\/ui\/Dialog'/)
assert.match(s, /<Dialog\b[^>]*title="Alles anhalten\?"/)
assert.ok(s.includes('Die Worker hören sofort auf, bis jemand fortsetzt.'))
// beide Knöpfe des Dialogs: type="button", Beschriftung „Anhalten“ und „Abbrechen“
assert.match(s, /<Button type="button" onClick=\{[^}]*\}[^>]*>Abbrechen<\/Button>/)
assert.match(s, /<Button variant="primary" type="button" onClick=\{confirmHalt\}[^>]*>Anhalten<\/Button>/)
// Der Knopf der Leiste fragt nur beim Anhalten nach; Fortsetzen ruft den Dienst ohne Dialog
assert.match(s, /<Button type="button" onClick=\{requestPause\}/)
assert.match(s, /const requestPause = \(\) => \{\s*if \(paused\) setPause\(false\)\s*else setConfirmOpen\(true\)\s*\}/)
// Bestätigen schließt den Dialog und hält an; der Zielwert steht fest (nicht !paused: sonst setzte ein veralteter Dialog die Arbeit wieder fort)
assert.match(s, /const confirmHalt = \(\) => \{\s*setConfirmOpen\(false\)\s*setPause\(true\)\s*\}/)
assert.match(s, /api\.pause\(target\)/); assert.doesNotMatch(s, /api\.pause\(!paused\)/)
// der Dialog steht nur offen, solange nicht schon angehalten ist (hat jemand anderes angehalten, entfällt die Frage)
assert.match(s, /\{confirmOpen && !paused && \(/)
// und die Frage taucht nach einem späteren Fortsetzen nicht von selbst wieder auf
assert.match(s, /useEffect\(\(\) => \{\s*if \(haltedByState\) setConfirmOpen\(false\)\s*\}, \[haltedByState\]\)/)
// Hooks vor der frühen Rückgabe
assert.ok(s.indexOf('useState(false)') < s.indexOf('if (!loaded || !state)') && s.indexOf('const [confirmOpen') < s.indexOf('if (!loaded || !state)'))
// PF-08 gilt weiter: jeder Knopf ein type="button"
assert.equal((s.match(/<Button\b/g) || []).length, (s.match(/<Button\b[^>]*type="button"/g) || []).length)
assert.doesNotMatch(s, /window\.confirm|confirm\(/)
})
// Endprüfung P3 (W3): Nach „Anhalten“ schließt der Dialog im selben Takt, in dem pausing beginnt und den Knopf der Leiste sperrt;
// useDialogFocus kann den Fokus dann nicht zurückgeben (focus() auf einem gesperrten Knopf tut nichts) und er bliebe auf <body>.
// Das Board gibt ihn deshalb selbst zurück, sobald pausing endet. Das echte Verhalten prüft der Prüfstand (shot.cjs, Szenario I).
test('WorkersBoard: der Fokus kehrt nach dem Umschalten auf den Knopf der Leiste zurück, sobald er wieder bedienbar ist (W3)', () => {
const s = read('WorkersBoard.jsx')
assert.match(s, /const toolbarRef = useRef\(null\)/); assert.match(s, /const refocusPauseRef = useRef\(false\)/)
assert.match(s, /<div className="wk-toolbar" ref=\{toolbarRef\}>/)
assert.match(s, /<Button type="button" onClick=\{requestPause\} disabled=\{pausing\} data-wk-pause>/)
// die Markierung wird vor setPausing(true) gesetzt, der Fokus erst im Effekt nach dem Ende von pausing (sonst wäre der Knopf noch gesperrt)
assert.match(s, /if \(pausing\) return\s*refocusPauseRef\.current = true\s*setPausing\(true\)/)
const effect = s.match(/useEffect\(\(\) => \{\s*if \(pausing \|\| !refocusPauseRef\.current\) return([\s\S]*?)\}, \[pausing\]\)/)
assert.ok(effect, 'Effekt mit [pausing]')
assert.match(effect[1], /refocusPauseRef\.current = false/)
// nur wenn der Fokus verloren ist (auf <body> oder nirgends): wer inzwischen woanders ist, wird nicht gestört
assert.match(effect[1], /const active = document\.activeElement\s*if \(active && active !== document\.body\) return/)
assert.match(effect[1], /toolbarRef\.current\?\.querySelector\('\[data-wk-pause\]'\)/); assert.match(effect[1], /button\.focus\(\)/)
// Hook vor der frühen Rückgabe
assert.ok(s.indexOf('[data-wk-pause]') < s.indexOf('if (!loaded || !state)'))
// das Gesperrt-Sein des Knopfes bleibt (kein Doppelklick während der Anfrage)
assert.match(s, /disabled=\{pausing\}/)
})
+60 -76
View File
@@ -1,32 +1,63 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
test('workers.css: nur wk-Klassen, @font-face, keine Hex-Farben, reduzierte Bewegung', () => {
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') // Kommentare vorher entfernen
for (const m of css.matchAll(/([^{}]+)\{/g)) { const pre = m[1].trim(); if (pre.startsWith('@')) continue; for (const sel of pre.split(',')) assert.ok(sel.trim().startsWith('.wk-'), sel) } // jede Regel beginnt mit einer wk-Klasse (Nachfahren dürfen globale Klassen nutzen)
assert.match(css, /@font-face\s*\{[^}]*font-family:\s*"Pixelify Sans"[^}]*PixelifySans-Variable\.woff2[^}]*font-display:\s*swap/s); assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b/)
assert.match(css, /prefers-reduced-motion/); assert.match(css, /@media \(max-width: 1100px\)/); assert.match(css, /@media \(max-width: 860px\)/)
// workers.css ist seit T11 (Pixelwelt) auf die weißen Bausteine gekürzt, die noch gebraucht werden: Einstellungen-Dialog (WorkersSettingsDialog)
// und Interessenten-Panel (ProspectPanel). Hochhaus, Pult und Keller haben ihre Stile in src/styles/hustler/ (eigene Tests: hustler*.test.mjs).
const stylesDir = fileURLToPath(new URL('../../src/styles/', import.meta.url))
const srcDir = fileURLToPath(new URL('../../src/', import.meta.url))
const stripped = (file) => readFileSync(path.join(stylesDir, file), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') // Kommentare vorher entfernen
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]))
const ohneKommentare = (code) => code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1')
// Klassen der Regeln (jede Regel, auch in @media) und Klassen im Code (ohne Kommentare; `wk-set-${name}` ist eine Kennung, keine Klasse;
// das Attribut data-wk-worker hat ein Zeichen davor und zählt nicht)
const definierteKlassen = (css) => new Set([...css.matchAll(/\.(wk-[a-z0-9-]+)/g)].map((m) => m[1]))
const benutzteKlassen = () => {
const out = new Map()
for (const f of walk(srcDir).filter((x) => /\.jsx?$/.test(x))) {
for (const m of ohneKommentare(readFileSync(f, 'utf8')).matchAll(/(?<![\w-])wk-[a-z0-9-]*[a-z0-9](?![\w-])/g)) {
if (!out.has(m[0])) out.set(m[0], new Set())
out.get(m[0]).add(path.relative(srcDir, f))
}
}
return out
}
test('workers.css: nur wk-Klassen, keine Hex-Farben, keine Schrift, Bilder oder Importe, ein Handy-Block', () => {
const css = stripped('workers.css')
let regeln = 0
for (const m of css.matchAll(/([^{}]+)\{/g)) {
const pre = m[1].trim()
if (pre.startsWith('@')) continue
regeln += 1
for (const sel of pre.split(',')) assert.ok(sel.trim().startsWith('.wk-'), sel) // jede Regel beginnt mit einer wk-Klasse (Nachfahren dürfen globale Klassen nutzen)
}
assert.ok(regeln >= 15, `nur ${regeln} Regeln gelesen`)
assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b/)
assert.doesNotMatch(css, /@font-face|@import|url\(/) // die Schrift steht in hustler/schrift.css (workersFont.test.mjs)
assert.match(css, /@media \(max-width: 860px\)/)
assert.doesNotMatch(css, /@media \(max-width: 1100px\)/)
// keine Bewegung in dieser Datei, also braucht sie auch kein prefers-reduced-motion
assert.doesNotMatch(css, /\b(?:animation|transition)\s*:|@keyframes/)
})
test('jede im JSX verwendete wk-Klasse ist definiert', () => {
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8'); const dir = new URL('../../src/components/workers/', import.meta.url)
for (const f of readdirSync(dir).filter((x) => x.endsWith('.jsx'))) for (const m of readFileSync(new URL(f, dir), 'utf8').matchAll(/(?<![\w-])wk-[a-z0-9-]+/g)) assert.ok(css.includes('.' + m[0]), `${f}: ${m[0]}`) // Lookbehind: das Attribut data-wk-worker ist keine Klasse
})
test('workers.css: Zoomknöpfe unten rechts (oben läge die Reihe über dem Schild der Kommandobrücke), Meldungen halten Abstand zu den Kennzahlen', () => {
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
const rule = (selector) => css.match(new RegExp(`${selector.replace(/[.~]/g, '\\$&')}\\s*\\{([^}]*)\\}`))?.[1] ?? ''
assert.match(rule('.wk-zoom'), /bottom:\s*8px/); assert.match(rule('.wk-zoom'), /right:\s*8px/); assert.doesNotMatch(rule('.wk-zoom'), /\btop:/)
assert.match(rule('.wk-toolbar ~ .msg'), /margin-bottom:\s*16px/)
})
test('workers.css: die Karte bleibt beim Anspringen unter der festen Leiste der Handy-Ansicht (scroll-margin-top)', () => {
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
assert.match(css, /\.wk-card\s*\{[^}]*scroll-margin-top:\s*12px/)
const phone = css.slice(css.indexOf('@media (max-width: 860px)'))
assert.match(phone, /\.wk-card\s*\{\s*scroll-margin-top:\s*64px/) // 52 px Leiste + Luft
test('jede wk-Klasse im Code hat eine Regel in workers.css, und keine Regel steht ohne Nutzer', () => {
const definiert = definierteKlassen(stripped('workers.css'))
const benutzt = benutzteKlassen()
assert.ok(definiert.size >= 10 && benutzt.size >= 10, `${definiert.size} Regel-Klassen, ${benutzt.size} benutzte Klassen`)
for (const [klasse, dateien] of benutzt) assert.ok(definiert.has(klasse), `${klasse} (${[...dateien].join(', ')}) hat keine Regel`)
for (const klasse of definiert) assert.ok(benutzt.has(klasse), `.${klasse} wird nirgends im Code benutzt`)
// die Nutzer sind genau die zwei weißen Dialoge
const nutzer = new Set([...benutzt.values()].flatMap((s) => [...s]))
assert.deepEqual([...nutzer].sort(), ['components/workers/ProspectPanel.jsx', 'components/workers/WorkersSettingsDialog.jsx'])
})
test('workers.css: die globale Regel .page section + section (36 px) verlängert den Abstand der Panel-Abschnitte nicht (W4)', () => {
const global = readFileSync(new URL('../../src/styles/global.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
const css = readFileSync(new URL('../../src/styles/workers.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
const global = stripped('global.css')
const css = stripped('workers.css')
assert.match(global, /\.page section \+ section\s*\{\s*margin-top:\s*36px/) // die Regel, gegen die das Panel sich behaupten muss: Spezifität (0,1,2)
const m = css.match(/(\.wk-prospect [^{}]*\.wk-prospect-section \+ \.wk-prospect-section)\s*\{([^}]*)\}/)
assert.ok(m, 'Regel für zwei benachbarte Abschnitte im Panel')
@@ -38,60 +69,13 @@ test('workers.css: die globale Regel .page section + section (36 px) verlängert
assert.doesNotMatch(css.match(/\.wk-prospect-section\s*\{([^}]*)\}/)[1], /margin-top/)
})
// ---- Bot-Leads (Endprüfung P6: C5, W1, W2) ----
const stripped = (file) => readFileSync(new URL('../../src/styles/' + file, import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// Rumpf der Regel mit genau diesem Selektor am Anfang der Regel (weder Teil eines längeren Selektors noch einer Gruppe mit Komma)
const body = (css, selector) => css.match(new RegExp(`(?:^|[{}])\\s*${escapeRe(selector)}\\s*\\{([^}]*)\\}`))?.[1] ?? null
const decl = (rule, prop) => rule?.match(new RegExp(`(?:^|[;\\s])${prop}\\s*:\\s*([^;]+)`))?.[1].trim() ?? null
// Spezifität (ids, Klassen/Attribute/Pseudoklassen, Elemente); Vergleich von links nach rechts
const specificity = (selector) => {
const s = selector.replace(/\[[^\]]*\]/g, '.a')
const ids = (s.match(/#[\w-]+/g) || []).length
const classes = (s.match(/\.[\w-]+|:(?!:)[\w-]+/g) || []).length
const elements = (s.replace(/\.[\w-]+|#[\w-]+|:+[\w-]+/g, ' ').match(/(?:^|[\s>+~])[a-z][\w-]*/gi) || []).length
return [ids, classes, elements]
}
const beats = (a, b) => { for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i]; return false }
const luminance = (hex) => {
const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
}
const contrast = (a, b) => { const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05) }
const token = (name) => stripped('global.css').match(new RegExp(`${name}:\\s*(#[0-9a-fA-F]{6})`))?.[1]
test('workers.css: gekürzte Zeilen der Bot-Leads bleiben lesbar (Text >= 4,5:1 auf dem Seiten- und dem Zeilengrund), grau über --text-2 statt --text-3 (C5)', () => {
const css = stripped('workers.css')
const rule = body(css, '.wk-bl-cut td')
assert.ok(rule, 'Regel .wk-bl-cut td')
assert.equal(decl(rule, 'color'), 'var(--text-2)')
assert.doesNotMatch(css, /\.wk-bl-cut[^{]*\{[^}]*--text-3/) // keine Regel der gekürzten Zeile greift zu --text-3
for (const ground of ['--bg', '--bg-subtle']) {
const ratio = contrast(token('--text-2'), token(ground))
assert.ok(ratio >= 4.5, `${ground}: ${ratio.toFixed(2)}:1`)
}
})
test('workers.css: am Handy sind die Blätterknöpfe, „Alle anzeigen“ und „Erneut versuchen“ der Bot-Leads 40 px hoch, ohne die globale .btn-Regel zu ändern (C5, W1)', () => {
const css = stripped('workers.css')
const phone = css.slice(css.indexOf('@media (max-width: 860px)'))
for (const selector of ['.wk-bl-pager .btn', '.wk-bl-single .btn', '.wk-bl-retry .btn']) {
assert.equal(decl(body(phone, selector), 'min-height'), '40px', selector)
// min-height schlägt height: 32px der globalen .btn-Regel, und die Spezifität (0,2,0) schlägt .btn (0,1,0): kein !important nötig
assert.ok(beats(specificity(selector), specificity('.btn')), selector)
assert.doesNotMatch(body(phone, selector), /!important/)
}
assert.equal(decl(body(stripped('global.css'), '.btn'), 'height'), '32px') // das, wogegen die Knöpfe sich behaupten
assert.equal(body(css, '.btn'), null) // keine globale Änderung aus der Hustler-Datei
})
test('workers.css: der Ort in der Bot-Leads-Tabelle bricht nicht mitten im Wort (break-word schlägt die globale Regel mit anywhere) (W2)', () => {
test('workers.css: Einstellungen-Dialog behält Trennlinien und den roten Block „Versand gestoppt“ (Tokens aus global.css)', () => {
const css = stripped('workers.css')
const global = stripped('global.css')
const wanted = '.wk-bl .wk-bl-table td .cell-sub'
const globalSelector = '.table td .cell-sub'
assert.equal(decl(body(global, globalSelector), 'overflow-wrap'), 'anywhere') // die globale Regel, die der Bot-Leads-Ort nicht erben soll
assert.equal(decl(body(css, wanted), 'overflow-wrap'), 'break-word')
// streng größer, nicht gleich: bei Gleichstand entschiede die Reihenfolge der Stylesheets im Bündel
assert.ok(beats(specificity(wanted), specificity(globalSelector)), `${wanted} ${specificity(wanted)} gegen ${specificity(globalSelector)}`)
const rule = (selector) => css.match(new RegExp(`${selector.replace(/[.~]/g, '\\$&')}\\s*\\{([^}]*)\\}`))?.[1] ?? ''
assert.match(rule('.wk-set-group'), /border-top:\s*1px solid var\(--line\)/)
assert.match(rule('.wk-halt'), /border-top:\s*1px solid var\(--danger\)/); assert.match(rule('.wk-halt'), /border-bottom:\s*1px solid var\(--danger\)/)
assert.match(rule('.wk-halt .wk-set-title'), /color:\s*var\(--red-fg\)/)
// jedes verwendete Token gibt es in global.css
for (const m of css.matchAll(/var\((--[\w-]+)\)/g)) assert.match(global, new RegExp(`${m[1]}\\s*:`), m[1])
})
-640
View File
@@ -1,640 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
import { makeCtx } from './helpers/fakeCtx.mjs'
import { IDS, makeState, ev } from './helpers/workersFixtures.mjs'
// Regie 1: Agenten, Snapshot ohne Animation, Posen, Leerlauf, Zeichnen; Regie 2: Schritte, Aufzugfahrten, Rückstau, Wachhund (Quelle: entwuerfe/scene-turm.js)
const D = await loadModule('src/components/workers/engine/director.js')
const { buildLayout, homeOf } = await loadModule('src/components/workers/engine/layout.js')
const { createLift, LIFT } = await loadModule('src/components/workers/engine/lift.js')
const { createFx } = await loadModule('src/components/workers/engine/fx.js')
const { createLook } = await loadModule('src/components/workers/engine/rooms.js')
const { pickAgent } = await loadModule('src/components/workers/engine/camera.js')
const { PAL } = await loadModule('src/components/workers/engine/sprites.js')
const { createDirector, drawAgents } = D
function setup() {
const layout = buildLayout(), lift = createLift(layout), fx = createFx(), look = createLook()
return { layout, lift, fx, look, d: createDirector({ layout, lift, fx, look, rnd: () => 0.5 }) }
}
const step = (d, n, dt = 0.1) => { for (let i = 0; i < n; i++) d.update(dt) }
const rectsOf = (ctx, fill) => ctx.rects.filter((r) => r.fill === fill)
const draw = (d, selectedId = null, t = 1) => { const m = makeCtx(); drawAgents(m.ctx, d, selectedId, t); return m }
const depth = (a) => a.y + a.lane + a.bob + 13 * a.scale
test('erster Snapshot: alle 19 an ihrem Platz, Pose nach Status, „!“ bei Fehler', () => {
const { d, layout } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf', rita: 'wartet', bea: 'fehler' } }))
assert.equal(d.agents.length, 19)
for (const a of d.agents) { const h = homeOf(layout, a.id); assert.deepEqual([a.x, a.y], [h.x, h.y], a.id) }
assert.deepEqual([d.byId.kim.pose, d.byId.rita.pose, d.byId.otto.pose, d.byId.bea.alert], ['coffee', 'stand', 'type', true])
})
test('erster Snapshot stellt Agenten auf den Platz, auch wenn sie vorher woanders standen', () => {
const { d, layout } = setup(); for (const a of d.agents) { a.x = 5; a.y = 7; a.carrying = true; a.frame = 1 }
d.applyState(makeState())
for (const a of d.agents) { const h = homeOf(layout, a.id); assert.deepEqual([a.x, a.y, a.carrying, a.frame, a.floor], [h.x, h.y, false, 0, h.floor], a.id) }
assert.deepEqual([d.byId.otto.lane, d.byId.bob.lane], [0, -2])
})
test('nicht angeschlossen oder im Zustand fehlend ⇒ grau, schläft, Raum in look.greyRooms (Review Focus 4)', () => {
const { d, look } = setup(); const st = makeState({ connected: { kim: false } }); st.workers = st.workers.filter((w) => w.workerId !== 'pia')
d.applyState(st)
assert.deepEqual([d.byId.kim.grey, d.byId.kim.pose, d.byId.pia.grey, d.byId.pia.pose, d.byId.bob.grey], [true, 'sleep', true, 'sleep', false])
assert.deepEqual([...look.greyRooms].sort(), ['kleinanzeigen', 'portale'])
})
test('Kennzahlen landen in look; neuer Pokal blinkt nur bei steigender Zahl, nicht beim ersten Snapshot', () => {
const { d, look } = setup(); d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 9, botLeads: 112 } }))
assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0])
d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 10, botLeads: 112 } })); assert.ok(look.trophyPop > 0)
d.applyState(makeState({ paused: true })); assert.equal(look.paused, true)
d.applyState(makeState({ sendingHalted: true, sendingHaltedReason: 'Zwei harte Bounces' })); assert.equal(look.paused, false) // Notbremse (H4) ist keine Pause: kein Schleier, die Meldung zeigt das Board
})
test('Leerlauf: erst Kaffee, nach 25 s Schlaf; Pokal-Seiten blättern alle 6 s', () => {
const { d, look } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf' }, kpis: { dealsMonth: 9 } }))
const step = (n) => { for (let i = 0; i < n; i++) d.update(0.1) }
step(70); assert.equal(look.trophyPage, 1) // 7 s: erste Blätterung bei 6 s
step(60); assert.equal(look.trophyPage, 0); assert.equal(d.byId.kim.pose, 'coffee') // 13 s: zweite Blätterung bei 12 s
step(140); assert.equal(d.byId.kim.pose, 'sleep') // 27 s > 25 s
})
test('drawAgents: Trefferflächen für alle 19, jeder ist anklickbar', () => {
const { d } = setup(); d.applyState(makeState({ connected: { kim: false }, statuses: { rita: 'fehler' } })); const { ctx } = makeCtx(); drawAgents(ctx, d, 'bob', 1)
const boxes = d.boxes(); assert.equal(boxes.length, 19)
for (const { id, box } of boxes) assert.equal(pickAgent(boxes, box.x + box.w / 2, box.y + box.h / 2), id, id)
})
test('Konstanten der Regie', () => {
assert.deepEqual([D.BACKLOG_KEEP, D.GROUPS_PER_AGENT, D.IDLE_COFFEE_S, D.STEP_TIMEOUT_S, D.BUBBLE_MAX], [6, 4, 25, 45, 3])
})
test('Agent hat alle Felder der Schnittstelle; Hut, Größe, Blickrichtung und Platz stimmen', () => {
const { d, layout } = setup(); d.applyState(makeState())
const felder = ['id', 'name', 'hat', 'scale', 'x', 'y', 'floor', 'facing', 'frame', 'pose', 'carrying', 'stackN', 'say', 'bubT', 'grey', 'alert', 'status', 'task', 'counters', 'lane', 'bob', 'idleT', 'cheer', 'q', 'cur', 'stepT', 'groups', 'box', 'home']
for (const a of d.agents) for (const f of felder) assert.ok(Object.hasOwn(a, f), `${a.id}.${f}`)
assert.deepEqual(d.agents.map((a) => a.id), [...IDS])
assert.deepEqual([d.byId.otto.hat, d.byId.otto.scale, d.byId.bob.hat, d.byId.paul.hat, d.byId.kim.hat, d.byId.kim.scale], ['overseer', 2, 'opus', 'sonnet', 'haiku', 1])
assert.deepEqual([d.byId.otto.name, d.byId.clara.name], ['Otto', 'Clara'])
assert.deepEqual(['ali', 'hanna', 'sven', 'lea', 'paul', 'clara'].map((id) => d.byId[id].facing), [-1, -1, -1, -1, -1, -1])
assert.deepEqual(['otto', 'rita', 'bea', 'arne'].map((id) => d.byId[id].facing), [1, 1, 1, 1])
assert.deepEqual(d.agents.map((a) => [a.floor, a.home]), d.agents.map((a) => [homeOf(layout, a.id).floor, homeOf(layout, a.id)]))
assert.deepEqual([d.byId.otto.lane, d.byId.bob.lane], [0, -2])
assert.equal(d.byId.constructor, undefined)
assert.equal(d.pending(), 0)
})
test('spätere Snapshots bewegen nie; Status, Pose und Aufgabe nur bei Agenten ohne laufende Gruppe', () => {
const { d } = setup(); d.applyState(makeState()); const kim = d.byId.kim
kim.x = 77; kim.y = 66
const st1 = makeState({ statuses: { kim: 'leerlauf' } }); st1.workers.find((w) => w.workerId === 'kim').task = 'Pause'; st1.workers.find((w) => w.workerId === 'kim').counters = { gelesen: 4 }
d.applyState(st1)
assert.deepEqual([kim.x, kim.y, kim.status, kim.pose, kim.task, kim.counters], [77, 66, 'leerlauf', 'coffee', 'Pause', { gelesen: 4 }])
// laufende Gruppe: der Status gilt sofort, Pose und Aufgabe kommen erst nach dem Ende der Gruppe
kim.groups = 1; kim.pose = 'type'; kim.task = 'alt'
const st2 = makeState({ statuses: { kim: 'fehler' } }); st2.workers.find((w) => w.workerId === 'kim').task = 'neu'
d.applyState(st2)
assert.deepEqual([kim.status, kim.alert, kim.pose, kim.task], ['fehler', true, 'type', 'alt'])
d.update(0.05); assert.equal(kim.pose, 'type')
kim.groups = 0; d.update(0.05); assert.equal(kim.pose, 'stand')
d.applyState(st2); assert.equal(kim.task, 'neu')
})
test('unbrauchbarer Zustand wirft nicht: alle grau und schlafend, keine Pause', () => {
for (const bad of [null, undefined, {}, 'x', { workers: 'x' }, { workers: [null, 5, { workerId: 7 }] }]) {
const { d, look } = setup(); d.applyState(makeState({ paused: true })); d.applyState(bad)
assert.deepEqual([d.agents.every((a) => a.grey && a.pose === 'sleep' && a.status === 'grau'), look.paused, look.greyRooms.size], [true, false, 19], String(bad))
}
})
test('busyRooms: Räume der arbeitenden Worker; graue und ruhende gehören nicht dazu', () => {
const { d, look } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf', bea: 'wartet', paul: 'fehler' }, connected: { pia: false } }))
assert.equal(look.busyRooms.has('bruecke'), true)
for (const room of ['kleinanzeigen', 'portale', 'waage', 'pruefung']) assert.equal(look.busyRooms.has(room), false, room)
assert.equal(look.busyRooms.size, 19 - 4)
})
test('Kennzahlen: fehlende Werte lassen look stehen und lösen kein Blinken aus; Rückgang blinkt nicht', () => {
const { d, look } = setup(); d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 9, botLeads: 112 } }))
const bare = makeState(); delete bare.kpis; d.applyState(bare)
assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0])
d.applyState(makeState({ kpis: { dealsMonth: 'x', pultOpen: NaN, botLeads: null } })); assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0])
d.applyState(makeState({ kpis: { dealsMonth: 9 } })); assert.equal(look.trophyPop, 0)
d.applyState(makeState({ kpis: { dealsMonth: 9.8, pultOpen: -3, botLeads: 112.9 } })); assert.deepEqual([look.dealsMonth, look.pultOpen, look.botLeads, look.trophyPop], [9, 0, 112, 0]) // ganze Zahlen, nie negativ
d.applyState(makeState({ kpis: { dealsMonth: 2 } })); assert.deepEqual([look.dealsMonth, look.trophyPop], [2, 0])
d.applyState(makeState({ kpis: { dealsMonth: 3 } })); assert.equal(look.trophyPop, 1.2)
// ein Zustand ohne Kennzahlen beim ersten Snapshot zählt nicht als erster Wert
const b = setup(); b.d.applyState(bare); b.d.applyState(makeState({ kpis: { dealsMonth: 9 } })); assert.equal(b.look.trophyPop, 0)
})
test('update: Pokal-Blinken und Tresor-Blitz zählen herunter; Pause friert alles ein', () => {
const { d, look, fx } = setup(); d.applyState(makeState({ kpis: { dealsMonth: 1 } })); d.applyState(makeState({ kpis: { dealsMonth: 2 } })); look.vaultFlash = 0.7
let ended = 0; fx.add(0.4, () => {}, () => { ended += 1 })
step(d, 5); assert.ok(look.trophyPop < 1.2 && look.trophyPop > 0.6); assert.ok(look.vaultFlash < 0.7 && look.vaultFlash > 0.1); assert.equal(ended, 1)
step(d, 20); assert.deepEqual([look.trophyPop, look.vaultFlash], [0, 0])
const kim = d.byId.kim; d.applyState(makeState({ statuses: { kim: 'leerlauf' }, paused: true })); look.pageT = 0
const before = [kim.idleT, look.pageT]; step(d, 100); assert.deepEqual([kim.idleT, look.pageT], before)
d.applyState(makeState({ statuses: { kim: 'leerlauf' }, paused: false })); step(d, 10); assert.ok(kim.idleT > 0.9)
})
test('update: ungültiges dt wird ignoriert; der Schlaf kehrt mit dem Status zurück', () => {
const { d } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf' } }))
for (const dt of [NaN, undefined, 0, -1, Infinity]) d.update(dt)
assert.equal(d.byId.kim.idleT, 0)
step(d, 260); assert.equal(d.byId.kim.pose, 'sleep')
d.applyState(makeState({ statuses: { kim: 'arbeitet' } })); d.update(0.1); assert.deepEqual([d.byId.kim.idleT, d.byId.kim.pose], [0, 'type'])
d.applyState(makeState({ statuses: { kim: 'leerlauf' } })); step(d, 10); assert.equal(d.byId.kim.pose, 'coffee')
})
test('Wippen: nur wer am Platz tippt, wippt um einen Pixel; Jubel hüpft um zwei; unterwegs gilt die Spur 0', () => {
const { d } = setup(); d.applyState(makeState()); const seen = new Set()
for (let i = 0; i < 40; i++) { d.update(0.1); seen.add(d.byId.bob.bob) }
assert.deepEqual([...seen].sort(), [-1, 0])
assert.equal(d.byId.arne.pose, 'type')
d.applyState(makeState({ statuses: { bob: 'wartet' } })); d.update(0.1); assert.equal(d.byId.bob.bob, 0)
d.byId.otto.cheer = 0.5; const jubel = new Set(); for (let i = 0; i < 4; i++) { d.update(0.1); jubel.add(d.byId.otto.bob) }
assert.ok(jubel.has(-2) && jubel.has(0)); step(d, 3); assert.equal(d.byId.otto.cheer, 0)
// Spur: am Platz -2 (außer Otto), unterwegs gleitet sie auf 0
const kim = d.byId.kim; assert.equal(kim.lane, -2); kim.x += 30; kim.frame = 1; const unterwegs = new Set(); for (let i = 0; i < 20; i++) { d.update(0.1); unterwegs.add(kim.bob) }
assert.deepEqual([kim.lane, [...unterwegs]], [0, [0]]); kim.x -= 30; kim.frame = 0; d.update(0.05); assert.ok(kim.lane < 0 && kim.lane > -1)
step(d, 3); assert.equal(kim.lane, -2)
})
test('bubble: auf 20 Zeichen gekürzt, nach 2,2 s weg, höchstens drei fremde gleichzeitig, leer und unbekannt ignoriert', () => {
const { d } = setup(); d.applyState(makeState())
assert.equal(d.bubble(d.byId.kim, 'Dies ist ein sehr langer Satz'), true); assert.equal(d.byId.kim.say, 'Dies ist ein sehr la')
assert.equal(d.bubble(d.byId.rita, 'Eins'), true); assert.equal(d.bubble(d.byId.pia, 'Zwei'), true)
assert.equal(d.bubble(d.byId.finn, 'Drei'), false); assert.equal(d.byId.finn.say, null)
assert.equal(d.bubble(d.byId.kim, 'Neu'), true); assert.equal(d.byId.kim.say, 'Neu') // der Sprecher selbst zählt nicht mit
assert.deepEqual([d.bubble(d.byId.kim, ''), d.bubble(d.byId.kim, 42), d.bubble(null, 'x')], [false, false, false])
step(d, 20); assert.equal(d.byId.kim.say, 'Neu'); step(d, 5); assert.deepEqual(d.agents.filter((a) => a.say), [])
assert.equal(d.bubble(d.byId.finn, 'Jetzt'), true)
d.bubble(d.byId.ali, 'Lang', 5); step(d, 30); assert.equal(d.byId.ali.say, 'Lang'); step(d, 25); assert.equal(d.byId.ali.say, null)
})
test('drawAgents: y-sortiert, jede Figur genau einmal, Trefferfläche aus der Zeichnung', () => {
const { d } = setup(); d.applyState(makeState()); const m = draw(d)
const tiefen = d.boxes().map(({ id }) => depth(d.byId[id]))
assert.deepEqual(tiefen, [...tiefen].sort((a, b) => a - b))
assert.equal(new Set(d.boxes().map((b) => b.id)).size, 19)
assert.ok(m.rects.length > 19 * 40)
const otto = d.byId.otto; assert.deepEqual([otto.box.w, otto.box.h], [24, 32])
const e = setup(); assert.deepEqual(e.d.boxes(), []) // vor dem ersten Zeichnen gibt es keine Flächen
})
test('drawAgents: gezeichnet wird von hinten nach vorn, nicht in Listenreihenfolge', () => {
const kleine = (m) => m.rects.map((r, i) => (r.w === 3 && r.h === 3 ? i : -1)).filter((i) => i >= 0) // Zellen der normalen Figuren (Raster 3)
const grosse = (m) => m.rects.map((r, i) => (r.w === 6 && r.h === 6 ? i : -1)).filter((i) => i >= 0) // Zellen von Otto (Maßstab 2)
const { d } = setup(); d.applyState(makeState())
const oben = draw(d); assert.ok(Math.max(...grosse(oben)) < Math.max(...kleine(oben)), 'Otto steht oben und wird zuerst gezeichnet')
d.byId.otto.y = 300
const unten = draw(d); assert.ok(Math.min(...grosse(unten)) > Math.max(...kleine(unten)), 'Otto steht jetzt vor allen anderen')
assert.equal(d.boxes().at(-1).id, 'otto')
})
test('drawAgents: „!“ nur bei Fehler, Auswahlpfeil nur beim gewählten, Stapel nur bei mehreren Papieren', () => {
const { d } = setup(); d.applyState(makeState())
assert.equal(rectsOf(draw(d), PAL.red).length, 0)
assert.equal(rectsOf(draw(d), PAL.ink).length, 0)
d.applyState(makeState({ statuses: { bea: 'fehler' } })); assert.ok(rectsOf(draw(d), PAL.red).length > 0)
const rot = rectsOf(draw(d), PAL.red), bea = d.byId.bea
assert.equal(Math.max(...rot.map((r) => r.y + r.h)) + 3, (bea.y + bea.lane - 2) * 3, 'Unterkante des „!“ (mit Umriss) steht auf der Kopfoberkante')
assert.equal(rectsOf(draw(d, 'bob'), PAL.ink).length, 9)
const papier = (n, carrying = true) => { d.byId.kim.carrying = carrying; d.byId.kim.stackN = n; return draw(d).rects.filter((r) => r.fill === PAL.g).length }
const eins = papier(1); assert.ok(papier(2) > eins); assert.ok(papier(5) > papier(2)); assert.equal(papier(5, false), papier(1, false))
})
test('drawAgents: graue Figuren halbdurchsichtig, die Zeichnung lässt save/restore im Gleichgewicht', () => {
const { d } = setup(); d.applyState(makeState({ connected: { kim: false, pia: false } })); const m = draw(d)
assert.equal(m.alphas.filter((a) => a === 0.55).length, 2); assert.equal(m.counts.save, m.counts.restore)
})
test('drawAgents: Blasen zuletzt; Otto seitlich; außerhalb des Bildes keine Blase', () => {
const { d } = setup(); d.applyState(makeState()); d.bubble(d.byId.kim, 'Hallo'); d.bubble(d.byId.otto, 'Deal! Super!')
const m = draw(d); assert.deepEqual(m.texts.map((t) => t.text).sort(), ['Deal! Super!', 'Hallo'])
const weiss = m.rects.map((r, i) => [r, i]).filter(([r]) => r.fill === PAL.w && r.w > 20)
assert.equal(weiss.length, 2); for (const [, i] of weiss) assert.ok(m.rects.length - i <= 4 + 4, 'Blase liegt am Ende der Zeichnung')
// Otto: die Blase steht neben ihm, nicht über dem Kopf (Spitze liegt links von seiner linken Kante oder rechts von der rechten)
const otto = d.byId.otto, os = m.texts.find((t) => t.text === 'Deal! Super!'), kimText = m.texts.find((t) => t.text === 'Hallo')
assert.ok(os.x < otto.x * 3 - 20 || os.x > (otto.x + 24) * 3); assert.ok(os.y > otto.y * 3)
assert.ok(kimText.y < d.byId.kim.y * 3)
const kim = d.byId.kim, spitze = m.rects.at(-1); assert.equal(spitze.y + spitze.h, (kim.y + kim.lane - 2) * 3 + 1, 'Spitze steht auf der Kopfoberkante (Hut)')
d.byId.kim.x = -10; d.byId.rita.x = 330; d.bubble(d.byId.rita, 'Rechts'); const aus = draw(d); assert.deepEqual(aus.texts.map((t) => t.text).sort(), ['Deal! Super!'])
})
test('drawAgents: die Blase weicht dem „!“ und dem Auswahlpfeil aus', () => {
const { d } = setup(); d.applyState(makeState({ statuses: { kim: 'fehler' } })); d.bubble(d.byId.kim, 'Kim'); d.bubble(d.byId.rita, 'Rita')
const textY = (m, text) => m.texts.find((t) => t.text === text).y
const m = draw(d)
assert.equal(d.byId.kim.y, d.byId.rita.y)
assert.ok(textY(m, 'Rita') - textY(m, 'Kim') >= 27) // das „!“ ist 9 Einheiten hoch (Raster 3)
assert.equal(textY(m, 'Rita') - textY(draw(d, 'rita'), 'Rita'), 18) // der Pfeil nimmt 6 Einheiten
})
// ---------- Regie 2: Schritte, Aufzugfahrten, Rückstau, Wachhund (P2-T13) ----------
// Simulationszeit in Sekunden, bis keine Gruppe mehr läuft (höchstens max)
const run = (d, max = 60) => {
let t = 0
while (d.pending() > 0 && t < max) {
d.update(0.05)
t += 0.05
}
return t
}
const at = (layout, id) => homeOf(layout, id)
test('Übergabe über zwei Etagen: Rita bringt Bea ein Dokument, fährt Aufzug, steht danach wieder zuhause', () => {
const { d, lift, layout } = setup()
d.applyState(makeState())
d.pushEvents([ev('uebergabe', 'rita', { to: 'bea', text: 'Rita bringt 2 Funde zu Bea' })])
assert.ok(d.pending() >= 1)
let ride = false
let t = 0
while (d.pending() > 0 && t < 60) {
d.update(0.05)
t += 0.05
if (lift.state !== 'idle') ride = true
}
assert.ok(ride)
assert.ok(t < 40, String(t))
const h = at(layout, 'rita')
assert.deepEqual([Math.round(d.byId.rita.x), Math.round(d.byId.rita.y), d.byId.rita.carrying, lift.state], [h.x, h.y, false, 'idle'])
assert.equal(d.byId.rita.task, 'Rita bringt 2 Funde zu Bea')
})
test('gleiche Etage: kein Aufzug', () => {
const { d, lift } = setup()
d.applyState(makeState())
d.pushEvents([ev('uebergabe', 'kim', { to: 'pia' })])
let ride = false
for (let i = 0; i < 1200 && d.pending() > 0; i++) {
d.update(0.05)
if (lift.state !== 'idle') ride = true
}
assert.equal(ride, false)
assert.equal(d.pending(), 0)
})
test('Rückstau: 300 Ereignisse ⇒ höchstens 6 Gruppen, Rest still, nach ≤ 60 s ruhig, letzte Aufgaben stehen (Review Focus 2)', () => {
const { d, lift } = setup()
d.applyState(makeState())
d.pushEvents(Array.from({ length: 300 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 3) % 19], text: `Aufgabe ${i}` })))
assert.ok(d.pending() <= 6, String(d.pending()))
const t = run(d)
assert.ok(t < 60, String(t))
assert.equal(d.pending(), 0)
assert.equal(lift.state, 'idle')
for (let k = 0; k < 19; k++) {
const last = 299 - ((299 - k) % 19)
assert.equal(d.byId[IDS[k]].task, `Aufgabe ${last}`, IDS[k])
}
d.pushEvents(Array.from({ length: 10 }, (_, i) => ev('fund', 'kim', { text: `K${i}` })))
assert.ok(d.byId.kim.groups <= 4)
})
test('graue, fremde und unbekannte Ereignisse: keine Arbeit, kein Wurf (Review Focus 4)', () => {
const { d } = setup()
d.applyState(makeState({ connected: { kim: false } }))
d.pushEvents([ev('gelesen', 'kim'), ev('fund', 'zed'), ev('neueArt', 'rita'), ev('uebergabe', 'kim', { to: 'bea' })])
assert.equal(d.byId.kim.say, null)
assert.equal(d.byId.kim.pose, 'sleep')
assert.equal(d.byId.kim.groups, 0)
})
test('Blasen: höchstens drei gleichzeitig', () => {
const { d } = setup()
d.applyState(makeState())
d.pushEvents(['kim', 'rita', 'pia', 'finn', 'ali'].map((w) => ev('fund', w)))
d.update(0.05)
assert.ok(d.agents.filter((a) => a.say).length <= 3)
})
test('Pause friert ein, Fortsetzen läuft weiter', () => {
const { d } = setup()
d.applyState(makeState({ paused: true }))
d.pushEvents([ev('uebergabe', 'kim', { to: 'pia' })])
const x0 = d.byId.kim.x
for (let i = 0; i < 400; i++) d.update(0.05)
assert.equal(d.byId.kim.x, x0)
d.applyState(makeState({ paused: false }))
for (let i = 0; i < 40; i++) d.update(0.05)
assert.notEqual(d.byId.kim.x, x0)
})
test('Aufzug zurückgesetzt mitten in der Fahrt: Fahrgast erholt sich, Türzähler 0; hängender Aufzug: Wachhund nach 45 s', () => {
const a = setup()
a.d.applyState(makeState())
a.d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' })])
let g = 0
while (a.lift.state !== 'riding' && g++ < 400) a.d.update(0.05)
assert.equal(a.lift.state, 'riding')
a.lift.reset()
assert.ok(run(a.d) < 10)
assert.deepEqual(a.d.debug().door, new Array(10).fill(0))
const layout = buildLayout()
const fx = createFx()
const look = createLook()
const stuck = { y: 0, state: 'idle', request: (ag, f, t) => ({ a: ag, from: f, to: t, st: 'queued' }), update() {}, cancel() {}, reset() {}, at: () => false, drawBack() {}, drawFront() {} }
const d = createDirector({ layout, lift: stuck, fx, look })
d.applyState(makeState())
d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' })])
const t = run(d, 80)
assert.ok(t >= 45 && t < 60, String(t))
assert.deepEqual(d.debug().door, new Array(10).fill(0))
})
test('flush: alles sofort ruhig, alle zuhause; Deal löst Münzen und Jubel aus', () => {
const { d, lift, fx, layout } = setup()
d.applyState(makeState())
d.pushEvents(Array.from({ length: 20 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 5) % 19] })))
d.flush()
assert.equal(d.pending(), 0)
assert.equal(lift.state, 'idle')
assert.equal(fx.size(), 0)
for (const a of d.agents) {
const h = at(layout, a.id)
assert.deepEqual([Math.round(a.x), Math.round(a.y)], [h.x, h.y], a.id)
}
d.pushEvents([ev('deal', 'system')])
d.update(0.1)
assert.ok(fx.size() >= 3)
assert.ok(d.byId.otto.cheer > 0)
run(d)
for (let i = 0; i < 100; i++) d.update(0.1)
assert.equal(fx.size(), 0)
})
// Aufzug-Attrappe: request liefert, was der Test vorgibt; alles Übrige tut nichts
const stubLift = (request, extra = {}) => ({ y: 0, state: 'idle', request, update() {}, cancel() {}, reset() {}, at: () => false, drawBack() {}, drawFront() {}, ...extra })
const withLift = (lift) => {
const layout = buildLayout()
const fx = createFx()
const look = createLook()
return { layout, fx, look, d: createDirector({ layout, lift, fx, look, rnd: () => 0.5 }) }
}
// Mittelpunkt aller gezeichneten Rechtecke in Welteinheiten (Raster 3)
const centerOf = (rects) => ({
x: rects.reduce((s, r) => s + r.x + r.w / 2, 0) / rects.length / 3,
y: rects.reduce((s, r) => s + r.y + r.h / 2, 0) / rects.length / 3,
})
const dist = (p, q) => Math.hypot(p.x - q.x, p.y - q.y)
test('Aufzug antwortet cancelled: Fahrt entfällt, der Agent steht sofort wieder zuhause, Türzähler 0', () => {
const calls = []
const lift = stubLift((a, from, to) => {
calls.push([a.id, from, to])
return { a, from, to, st: 'cancelled' }
})
const { d, layout } = withLift(lift)
d.applyState(makeState())
d.pushEvents([ev('uebergabe', 'rita', { to: 'bea', text: 'Rita bringt Bea Funde' })])
assert.ok(run(d) < 5)
assert.deepEqual(calls, [['rita', 1, 3]])
const h = at(layout, 'rita')
assert.deepEqual([d.byId.rita.x, d.byId.rita.y, d.byId.rita.floor, d.byId.rita.carrying, d.byId.rita.frame], [h.x, h.y, h.floor, false, 0])
assert.deepEqual(d.debug().door, new Array(10).fill(0))
assert.deepEqual([d.byId.rita.task, d.byId.bea.say, d.pending()], ['Rita bringt Bea Funde', null, 0])
})
test('Wachhund: ruft lift.cancel für den Agenten, stellt ihn heim und beendet die Gruppe', () => {
const cancelled = []
const lift = stubLift((a, from, to) => ({ a, from, to, st: 'queued' }), { cancel: (a) => cancelled.push(a.id) })
const { d, layout } = withLift(lift)
d.applyState(makeState())
d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' }), ev('fund', 'rita')])
assert.equal(d.byId.rita.groups, 2)
const t = run(d, 200)
assert.deepEqual(cancelled, ['rita'])
assert.ok(t >= 45 && t < 60, String(t))
const h = at(layout, 'rita')
assert.deepEqual([d.byId.rita.x, d.byId.rita.y, d.byId.rita.carrying, d.byId.rita.groups, d.byId.rita.cur], [h.x, h.y, false, 0, null])
})
test('Wartende vor dem Aufzug stehen nebeneinander (Türzähler zählt mit) und werden beim Einsteigen ausgetragen', () => {
const { d } = setup()
d.applyState(makeState())
d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' }), ev('uebergabe', 'kim', { to: 'max' })])
let most = 0
const slots = { rita: false, kim: false }
for (let i = 0; i < 1200 && d.pending() > 0; i++) {
d.update(0.05)
most = Math.max(most, d.debug().door[1])
if (d.byId.rita.x === LIFT.slot0) slots.rita = true
if (d.byId.kim.x === LIFT.slot0 + LIFT.slotStep) slots.kim = true
}
assert.equal(most, 2)
assert.deepEqual(slots, { rita: true, kim: true })
assert.deepEqual(d.debug().door, new Array(10).fill(0))
})
test('Snapshot während einer Gruppe: Aufgabe und Pose kommen erst am Gruppenende aus der Tabelle', () => {
const { d } = setup()
d.applyState(makeState())
d.pushEvents([ev('uebergabe', 'kim', { to: 'pia', text: 'Kim bringt Pia Funde' })])
for (let i = 0; i < 20; i++) d.update(0.05)
const st = makeState({ statuses: { kim: 'leerlauf' } })
st.workers.find((w) => w.workerId === 'kim').task = 'Snapshot-Aufgabe'
d.applyState(st)
assert.deepEqual([d.byId.kim.status, d.byId.kim.task], ['leerlauf', 'Kim bringt Pia Funde'])
run(d)
assert.deepEqual([d.byId.kim.task, d.byId.kim.pose, d.byId.kim.groups], ['Snapshot-Aufgabe', 'coffee', 0])
})
test('stille Gruppe für einen Worker mit laufender Gruppe: seine Aufgabe kommt am Gruppenende aus der Tabelle', () => {
const { d } = setup()
d.applyState(makeState())
d.pushEvents([ev('uebergabe', 'kim', { to: 'pia', text: 'Kim bringt Pia Funde' })])
d.update(0.05)
d.pushEvents([ev('gelesen', 'kim', { text: 'Liest Mails' }), ...Array.from({ length: 6 }, (_, i) => ev('fund', 'rita', { text: `R${i}` }))])
assert.equal(d.byId.kim.task, 'Kim bringt Pia Funde')
run(d)
assert.equal(d.byId.kim.task, 'Liest Mails')
})
test('pro Worker höchstens 4 wartende Gruppen: die neuesten bleiben animiert, die Aufgabe des neuesten gewinnt', () => {
const { d } = setup()
d.applyState(makeState())
d.pushEvents(Array.from({ length: 10 }, (_, i) => ev('fund', 'kim', { text: `K${i}` })))
assert.equal(d.byId.kim.groups, 4)
assert.equal(d.byId.kim.task, 'K5')
d.pushEvents([ev('fund', 'kim', { text: 'K10' })])
assert.equal(d.byId.kim.groups, 4)
run(d)
assert.equal(d.byId.kim.task, 'K10')
})
test('pushEvents vor dem ersten Snapshot und mit kaputten Eingaben: nichts passiert, kein Wurf', () => {
const { d, look } = setup()
d.pushEvents([ev('fund', 'kim')])
assert.deepEqual([d.pending(), d.agents.some((a) => a.grey), look.greyRooms.size, d.byId.kim.status], [0, false, 0, 'wartet'])
d.applyState(makeState())
for (const bad of [null, undefined, 'x', 5, {}, [], [null, 5, 'x', {}, { kind: 'fund' }]]) d.pushEvents(bad)
assert.equal(d.pending(), 0)
})
test('Ereignisse ändern den Status sofort (Fehler-Zeichen, Leerlauf, Räume), die Aufgabe setzt erst der Schritt', () => {
const { d, look } = setup()
d.applyState(makeState())
d.pushEvents([ev('fehler', 'rita', { text: 'Suche kaputt' }), ev('leerlauf', 'kim', { text: 'Pause' })])
assert.deepEqual([d.byId.rita.status, d.byId.rita.alert, d.byId.kim.status], ['fehler', true, 'leerlauf'])
assert.deepEqual([look.busyRooms.has('netz'), look.busyRooms.has('kleinanzeigen'), look.busyRooms.has('portale')], [false, false, true])
assert.equal(d.byId.rita.task, '')
d.update(0.05)
assert.deepEqual([d.byId.rita.task, d.byId.kim.task, d.byId.kim.pose], ['Suche kaputt', 'Pause', 'coffee'])
})
test('wer schläft, wird für seine Gruppe wach; danach gilt wieder der Status', () => {
const { d } = setup()
d.applyState(makeState({ statuses: { kim: 'leerlauf' } }))
for (let i = 0; i < 300; i++) d.update(0.1)
assert.equal(d.byId.kim.pose, 'sleep')
d.pushEvents([ev('doppelt', 'kim')])
d.update(0.05)
assert.deepEqual([d.byId.kim.pose, d.byId.kim.say], ['type', 'Schon bekannt'])
run(d)
assert.equal(d.byId.kim.pose, 'type')
})
test('Übergabe an einen nicht angeschlossenen Worker entfällt: niemand läuft, nur die Aufgabe steht', () => {
const { d, lift } = setup()
d.applyState(makeState({ connected: { kim: false } }))
const x0 = d.byId.pia.x
d.pushEvents([ev('uebergabe', 'pia', { to: 'kim', text: 'Pia an Kim' })])
for (let i = 0; i < 20; i++) d.update(0.05)
assert.equal(d.pending(), 0)
assert.deepEqual([d.byId.pia.x, d.byId.pia.task, d.byId.kim.say, d.byId.kim.pose, lift.state], [x0, 'Pia an Kim', null, 'sleep', 'idle'])
})
test('Übergabe auf einer Etage: Dokument in der Hand, Giver steht neben dem Empfänger (Abstand 12), blickt ihn an, Empfänger dankt', () => {
const { d, layout } = setup()
d.applyState(makeState())
const kim = d.byId.kim
const pia = d.byId.pia
const bea = d.byId.bea
d.pushEvents([ev('uebergabe', 'kim', { to: 'pia' }), ev('uebergabe', 'pia', { to: 'kim' })])
let carried = false
const seen = {}
for (let i = 0; i < 800 && d.pending() > 0; i++) {
d.update(0.05)
if (kim.carrying) carried = true
if (!seen.kim && pia.say === 'Danke!') seen.kim = { x: kim.x, facing: kim.facing, carrying: kim.carrying }
if (!seen.pia && kim.say === 'Danke!') seen.pia = { x: pia.x, facing: pia.facing, carrying: pia.carrying }
}
assert.equal(carried, true)
assert.deepEqual(seen.kim, { x: at(layout, 'pia').x - 12, facing: 1, carrying: false })
assert.deepEqual(seen.pia, { x: at(layout, 'kim').x + 12, facing: -1, carrying: false })
assert.equal(bea.say, null)
for (const a of [kim, pia]) assert.deepEqual([a.x, a.y, a.facing], [at(layout, a.id).x, at(layout, a.id).y, 1])
})
test('Effekte: Deal wirft drei Münzen von außen zum Tresor (Versatz 0, 0,4 und 0,8 s), der Tresor blitzt beim Ankommen, Otto jubelt', () => {
const calls = []
const fx = { add: (life, draw, onEnd, delay) => calls.push({ life, draw, onEnd, delay }), update() {}, clear() {}, size: () => calls.length }
const layout = buildLayout()
const look = createLook()
const d = createDirector({ layout, lift: createLift(layout), fx, look, rnd: () => 0.5 })
d.applyState(makeState())
d.pushEvents([ev('deal', 'system')])
d.update(0.05)
assert.deepEqual(calls.map((c) => c.delay), [0, 0.4, 0.8])
assert.deepEqual([look.vaultFlash, d.byId.otto.say], [0, 'Deal! Super!'])
calls[2].onEnd()
assert.equal(look.vaultFlash, 0.7)
const at0 = makeCtx()
calls[0].draw(at0.ctx, 0, { t: 0, life: calls[0].life })
const at1 = makeCtx()
calls[0].draw(at1.ctx, 1, { t: calls[0].life, life: calls[0].life })
assert.ok(dist(centerOf(at0.rects), layout.spots.aussen) < 4)
assert.ok(dist(centerOf(at1.rects), layout.spots.tresor) < 4)
})
test('Effekte: Ablegen ohne Ziel fliegt vom Kopf des Workers zum Archiv', () => {
const calls = []
const fx = { add: (life, draw, onEnd, delay) => calls.push({ life, draw, onEnd, delay }), update() {}, clear() {}, size: () => calls.length }
const layout = buildLayout()
const d = createDirector({ layout, lift: createLift(layout), fx, look: createLook(), rnd: () => 0.5 })
d.applyState(makeState())
d.pushEvents([ev('botlead', 'arne')])
for (let i = 0; i < 60 && calls.length === 0; i++) d.update(0.05)
assert.equal(calls.length, 1)
const arne = d.byId.arne
const start = makeCtx()
calls[0].draw(start.ctx, 0, { t: 0, life: calls[0].life })
const end = makeCtx()
calls[0].draw(end.ctx, 1, { t: calls[0].life, life: calls[0].life })
assert.ok(dist(centerOf(start.rects), { x: arne.x + 6, y: arne.y + arne.lane - 2 }) < 5)
assert.ok(dist(centerOf(end.rects), layout.spots.archiv) < 6)
})
test('flush läuft auch bei Pause durch und lässt alle zuhause', () => {
const { d, layout, look } = setup()
d.applyState(makeState({ paused: true }))
d.pushEvents([ev('uebergabe', 'rita', { to: 'bea', text: 'Rita bringt Bea Funde' })])
assert.equal(d.pending(), 1)
d.flush()
const h = at(layout, 'rita')
assert.deepEqual([d.pending(), look.paused, d.byId.rita.x, d.byId.rita.y], [0, true, h.x, h.y])
assert.equal(d.byId.rita.task, 'Rita bringt Bea Funde') // die Schritte sind durchgelaufen, nicht nur verworfen
})
test('reset: Warteschlangen leer, Aufzug frei, Türzähler 0, Effekte und Blasen weg, alle zuhause', () => {
const { d, lift, fx, layout } = setup()
d.applyState(makeState())
d.pushEvents([ev('uebergabe', 'rita', { to: 'bea' }), ev('fund', 'kim')])
let g = 0
while (lift.state !== 'riding' && g++ < 400) d.update(0.05)
assert.equal(lift.state, 'riding')
fx.add(5, () => {})
assert.ok(d.pending() > 0)
d.reset()
assert.deepEqual([d.pending(), lift.state, fx.size()], [0, 'idle', 0])
assert.deepEqual(d.debug(), { door: new Array(10).fill(0), groups: 0 })
for (const a of d.agents) {
const h = at(layout, a.id)
assert.deepEqual([a.x, a.y, a.carrying, a.say, a.q.length, a.cur], [h.x, h.y, false, null, 0, null], a.id)
}
const x = d.byId.rita.x
for (let i = 0; i < 100; i++) d.update(0.05)
assert.deepEqual([d.byId.rita.x, d.pending(), lift.state], [x, 0, 'idle'])
})
test('graue Worker bekommen keine vorgespielte Arbeit: weder Aufgabe noch Pose noch Blase noch Gruppe', () => {
const { d } = setup()
d.applyState(makeState({ connected: { kim: false } }))
d.pushEvents([ev('fund', 'kim', { text: 'Fund' }), ev('gelesen', 'kim', { text: 'Gelesen' }), ev('auftrag', 'otto', { to: 'kim', text: 'Kim, los' })])
for (let i = 0; i < 100; i++) d.update(0.05)
assert.deepEqual([d.byId.kim.task, d.byId.kim.pose, d.byId.kim.say, d.byId.kim.groups, d.byId.kim.status], ['', 'sleep', null, 0, 'grau'])
assert.equal(d.byId.otto.task, 'Kim, los')
})
test('Fahrgast steht beim Einsteigen schon in der Kabine, auch der hohe Otto, und kommt danach heim', () => {
const { d, lift, layout } = setup()
d.applyState(makeState())
const otto = d.byId.otto
d.pushEvents([ev('uebergabe', 'otto', { to: 'rita' })])
let boarded = null
for (let i = 0; i < 1200 && d.pending() > 0; i++) {
d.update(0.05)
if (!boarded && lift.state === 'closing') boarded = [otto.x, otto.y, otto.y === lift.y + 7]
}
assert.deepEqual(boarded, [LIFT.cabWx, boarded[1], true])
const h = at(layout, 'otto')
assert.deepEqual([otto.x, otto.y, otto.floor, lift.state, d.pending()], [h.x, h.y, h.floor, 'idle', 0])
})
test('Übergabe vom Aufzug aus: der Giver blickt den Empfänger an, auch wenn er dafür zurücklaufen muss', () => {
const { d, layout } = setup()
d.applyState(makeState())
const bea = d.byId.bea
const rita = d.byId.rita
d.pushEvents([ev('uebergabe', 'bea', { to: 'rita' })])
let seen = null
for (let i = 0; i < 1200 && d.pending() > 0; i++) {
d.update(0.05)
if (!seen && rita.say === 'Danke!') seen = [bea.x, bea.facing]
}
assert.deepEqual(seen, [at(layout, 'rita').x - 12, 1])
assert.ok(seen[0] < LIFT.exitX) // der Weg führt vom Ausgang des Schachts nach links zurück
})
+31 -1
View File
@@ -1,8 +1,38 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
test('Schrift: woff2-Magic-Bytes, plausible Größe, OFL-Lizenztext', () => {
const buf = readFileSync(new URL('../../public/fonts/PixelifySans-Variable.woff2', import.meta.url)); assert.equal(buf.subarray(0, 4).toString('latin1'), 'wOF2'); assert.ok(buf.length > 10000 && buf.length < 400000, String(buf.length))
const lic = readFileSync(new URL('../../public/fonts/OFL-PixelifySans.txt', import.meta.url), 'utf8'); assert.match(lic, /SIL OPEN FONT LICENSE Version 1\.1/); assert.match(lic, /Pixelify Sans/)
})
// Seit T11 (Pixelwelt) steht die Einbindung von Pixelify Sans neben Press Start 2P in styles/hustler/schrift.css; workers.css hat keine Schrift mehr.
const stylesDir = fileURLToPath(new URL('../../src/styles/', import.meta.url))
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]))
const ohneKommentare = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '')
const fontFaces = () => walk(stylesDir)
.filter((f) => f.endsWith('.css'))
.flatMap((f) => [...ohneKommentare(readFileSync(f, 'utf8')).matchAll(/@font-face\s*\{[^}]*\}/g)].map((m) => ({ datei: path.relative(stylesDir, f), regel: m[0] })))
test('Pixelify Sans: genau eine @font-face-Regel in src/styles, in hustler/schrift.css (selbst gehostet, swap, Gewichte 400-700)', () => {
const pixelify = fontFaces().filter((r) => /font-family:\s*["']Pixelify Sans["']/.test(r.regel))
assert.equal(pixelify.length, 1, 'genau eine Einbindung')
assert.equal(pixelify[0].datei, 'hustler/schrift.css')
const regel = pixelify[0].regel
assert.match(regel, /src:\s*url\(["']?\/fonts\/PixelifySans-Variable\.woff2["']?\)\s*format\(["']woff2["']\)/)
assert.match(regel, /font-display:\s*swap/)
assert.match(regel, /font-weight:\s*400 700/)
assert.doesNotMatch(regel, /googleapis|gstatic|https?:/)
// workers.css bindet keine Schrift mehr ein
assert.doesNotMatch(ohneKommentare(readFileSync(path.join(stylesDir, 'workers.css'), 'utf8')), /@font-face/)
})
test('jede selbst gehostete Schrift in src/styles liegt unter public/fonts und hat dort ihren Lizenztext', () => {
const dateien = fontFaces().flatMap((r) => [...r.regel.matchAll(/url\(\s*["']?(\/fonts\/[\w.-]+)["']?\s*\)/g)].map((m) => m[1]))
assert.ok(dateien.length >= 2, 'mindestens Press Start 2P und Pixelify Sans')
for (const datei of dateien) assert.ok(existsSync(fileURLToPath(new URL('../../public' + datei, import.meta.url))), `${datei} fehlt in public/`)
for (const lizenz of ['OFL-PressStart2P.txt', 'OFL-PixelifySans.txt']) assert.ok(existsSync(fileURLToPath(new URL('../../public/fonts/' + lizenz, import.meta.url))), lizenz)
})
-162
View File
@@ -1,162 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
import { makeCtx } from './helpers/fakeCtx.mjs'
// Effekt-System: Verzögerung, Lebensdauer, Obergrenze und die acht benannten Effekte
const F = await loadModule('src/components/workers/engine/fx.js')
const { PAL } = await loadModule('src/components/workers/engine/sprites.js')
test('Verzögerung, Lebensdauer, onEnd genau einmal', () => {
const fx = F.createFx(), log = []; fx.add(1, (c, p) => log.push(['draw', p]), () => log.push(['end']), 0.5)
const { ctx } = makeCtx(); fx.update(0.4); fx.draw(ctx); assert.equal(log.length, 0)
fx.update(0.4); fx.draw(ctx); assert.equal(log.at(-1)[0], 'draw')
fx.update(1); assert.deepEqual(log.at(-1), ['end']); fx.update(1); assert.equal(log.filter((l) => l[0] === 'end').length, 1); assert.equal(fx.size(), 0)
})
test('Obergrenze 80: Überschuss wird verworfen und meldet onEnd; clear ruft kein onEnd', () => {
const fx = F.createFx(); let ended = 0
for (let i = 0; i < 200; i++) fx.add(5, () => {}, () => { ended++ })
assert.equal(fx.size(), F.MAX_FX); assert.equal(ended, 120); fx.clear(); assert.equal(fx.size(), 0); assert.equal(ended, 120)
})
test('jeder Effektname zeichnet und endet in höchstens 6 s', () => {
for (const name of F.FX_NAMES) { const fx = F.createFx(), { ctx, rects } = makeCtx(); let ended = false
F.spawnFx(fx, name, { from: { x: 50, y: 100 }, to: { x: 150, y: 60 }, onEnd: () => { ended = true } })
for (let i = 0; i < 60; i++) { fx.update(0.1); fx.draw(ctx) }
assert.ok(rects.length > 0, name); assert.equal(fx.size(), 0, name); assert.ok(ended, name) }
})
// ---- Ergänzungen: Verhalten, das die drei Tests oben nicht berühren ----
const K = 3 // Standardraster der Engine (kein setGrid): Geräte-Pixel = 3 × logische Einheiten
const LIFE = { paper: 0.9, envelope: 3.4, coin: 0.7, stampOk: 1.2, stampNo: 1.2, sparkle: 1.0, alert: 1.5, drawer: 0.9 }
// Umschließendes Rechteck aller Rechtecke in logischen Einheiten (null ohne Rechtecke)
function bounds(rects) {
if (!rects.length) return null
const x0 = Math.min(...rects.map((r) => r.x)) / K, y0 = Math.min(...rects.map((r) => r.y)) / K
const x1 = Math.max(...rects.map((r) => r.x + r.w)) / K, y1 = Math.max(...rects.map((r) => r.y + r.h)) / K
return { x0, y0, x1, y1, cx: (x0 + x1) / 2, cy: (y0 + y1) / 2 }
}
// Ein Bild des Effekts nach `at` Sekunden (frischer Kontext)
function shot(name, opts, at) {
const fx = F.createFx(), { ctx, rects } = makeCtx()
F.spawnFx(fx, name, opts)
if (at > 0) fx.update(at)
fx.draw(ctx)
return { b: bounds(rects), rects, fx }
}
const FROM = { x: 40, y: 100 }
test('FX_NAMES und MAX_FX wie vereinbart, die Liste ist eingefroren', () => {
assert.deepEqual([...F.FX_NAMES], ['paper', 'envelope', 'coin', 'stampOk', 'stampNo', 'sparkle', 'alert', 'drawer'])
assert.ok(Object.isFrozen(F.FX_NAMES)); assert.equal(F.MAX_FX, 80)
})
test('Lebensdauer je Effekt: kurz vor dem Ende noch da, kurz danach weg und onEnd genau einmal', () => {
for (const name of F.FX_NAMES) {
const fx = F.createFx(); let ended = 0
F.spawnFx(fx, name, { from: FROM, to: { x: 140, y: 100 }, onEnd: () => { ended++ } })
fx.update(LIFE[name] - 0.05); assert.equal(fx.size(), 1, name); assert.equal(ended, 0, name)
fx.update(0.1); assert.equal(fx.size(), 0, name); assert.equal(ended, 1, name)
fx.update(1); assert.equal(ended, 1, name)
}
})
test('draw bekommt Fortschritt 0..1 (nie darüber) und den Effekt mit Zeit und Lebensdauer', () => {
const fx = F.createFx(), { ctx } = makeCtx(), seen = []
fx.add(1, (c, p, f) => seen.push([p, f.t, f.life]))
fx.draw(ctx); for (let i = 0; i < 9; i++) { fx.update(0.1); fx.draw(ctx) }
assert.equal(seen.length, 10); assert.deepEqual(seen[0], [0, 0, 1])
for (let i = 1; i < seen.length; i++) assert.ok(seen[i][0] >= seen[i - 1][0] && seen[i][0] <= 1)
assert.ok(seen.at(-1)[0] > 0.85)
const solo = F.createFx(); let p = null; solo.add(0, (c, q) => { p = q }); solo.draw(ctx); assert.equal(p, 1) // Lebensdauer 0: kein NaN
})
test('update ignoriert dt, das keine positive endliche Zahl ist (kein NaN im Zeitstand)', () => {
const fx = F.createFx(); let ended = 0; fx.add(1, () => {}, () => { ended++ })
fx.update(NaN); fx.update(-5); fx.update(0); fx.update(Infinity); fx.update(undefined)
assert.equal(fx.size(), 1); fx.update(1.1); assert.equal(fx.size(), 0); assert.equal(ended, 1)
})
test('ungültige Lebensdauer und Verzögerung laufen höchstens bis zum nächsten update', () => {
const fx = F.createFx(); fx.add(NaN, () => {}); fx.add(-3, () => {}); fx.add(1, () => {}, undefined, NaN); fx.add(1, () => {}, undefined, -2)
assert.equal(fx.size(), 4); fx.update(0.1); assert.equal(fx.size(), 2)
})
test('ein onEnd darf neue Effekte anlegen und clear() rufen; clear mitten im update wirft nicht', () => {
const fx = F.createFx(), log = []
fx.add(0.5, () => {}, () => { log.push('a'); fx.add(0.5, () => {}, () => log.push('b')) })
fx.update(0.6); assert.equal(fx.size(), 1); assert.deepEqual(log, ['a'])
fx.update(0.6); assert.deepEqual(log, ['a', 'b']); assert.equal(fx.size(), 0)
fx.add(0.5, () => {}, () => { log.push('c'); fx.clear() }); fx.add(5, () => {}, () => log.push('nie'))
fx.update(0.6); assert.equal(fx.size(), 0); assert.deepEqual(log, ['a', 'b', 'c'])
})
test('wirft ein onEnd, laufen die übrigen trotzdem und der erste Fehler kommt danach', () => {
const fx = F.createFx(); let second = 0
fx.add(0.1, () => {}, () => { throw new Error('boom') }); fx.add(0.1, () => {}, () => { second++ }); fx.add(5, () => {})
assert.throws(() => fx.update(0.2), /boom/); assert.equal(second, 1); assert.equal(fx.size(), 1)
})
test('voll: ein verworfener Effekt meldet onEnd sofort (nicht erst beim nächsten update)', () => {
const fx = F.createFx(); for (let i = 0; i < F.MAX_FX; i++) fx.add(5, () => {})
let ended = 0; F.spawnFx(fx, 'coin', { from: FROM, onEnd: () => { ended++ } })
assert.equal(ended, 1); assert.equal(fx.size(), F.MAX_FX)
})
test('spawnFx: Verzögerung zeichnet erst danach; unbekannter Name wirft nicht und meldet onEnd sofort', () => {
const fx = F.createFx(), { ctx, rects } = makeCtx()
F.spawnFx(fx, 'paper', { from: FROM, to: { x: 140, y: 100 }, delay: 0.5 })
fx.update(0.4); fx.draw(ctx); assert.equal(rects.length, 0)
fx.update(0.3); fx.draw(ctx); assert.ok(rects.length > 0)
let ended = 0; const empty = F.createFx()
for (const bad of ['gibtsnicht', '', undefined, null, 'constructor', '__proto__']) F.spawnFx(empty, bad, { from: FROM, onEnd: () => { ended++ } })
assert.equal(ended, 6); assert.equal(empty.size(), 0)
F.spawnFx(empty, 'sparkle'); assert.equal(empty.size(), 1) // ohne from/to und ohne Optionen: Standardpunkt statt Wurf
})
test('Bogenflug: Papier 8, Brief 12, Münze 14 Einheiten über der Geraden, Start und Ziel liegen auf der Geraden', () => {
const to = { x: 140, y: 100 } // gleiche Höhe: jede Abweichung in y ist der Bogen
for (const [name, high] of [['paper', 8], ['envelope', 12], ['coin', 14]]) {
const start = shot(name, { from: FROM, to }, 0).b, mid = shot(name, { from: FROM, to }, LIFE[name] / 2).b, end = shot(name, { from: FROM, to }, LIFE[name] - 0.001).b
assert.ok(Math.abs(start.cy - mid.cy - high) <= 1, `${name} Bogenhöhe ${start.cy - mid.cy}`)
assert.ok(Math.abs(start.cy - end.cy) <= 1, `${name} Ende auf der Geraden`)
assert.ok(Math.abs(start.cx - FROM.x) <= 1, `${name} Start bei from`); assert.ok(Math.abs(mid.cx - 90) <= 1, `${name} Mitte`); assert.ok(Math.abs(end.cx - to.x) <= 1, `${name} Ende bei to`)
}
})
test('Bogenflug folgt auch einem schrägen Weg (Ziel oben rechts)', () => {
const to = { x: 140, y: 60 }, start = shot('paper', { from: FROM, to }, 0).b, end = shot('paper', { from: FROM, to }, 0.899).b
assert.ok(end.cx - start.cx >= 98); assert.ok(start.cy - end.cy >= 38)
})
test('Briefumschlag ist violett mit weißem Fenster, Münze golden, Papier grau-weiß', () => {
const fills = (name) => new Set(shot(name, { from: FROM, to: { x: 140, y: 100 } }, 0.2).rects.map((r) => r.fill))
assert.ok(fills('envelope').has('#7a5cd6') && fills('envelope').has(PAL.w))
assert.ok(fills('coin').has(PAL.gold2)); assert.ok(fills('paper').has(PAL.g) && fills('paper').has(PAL.w))
})
test('Schubladen-Papier endet 3 Einheiten vor dem Ziel, das normale Papier erreicht es', () => {
const to = { x: 140, y: 100 }, d = shot('drawer', { from: FROM, to }, 0.899).b, p = shot('paper', { from: FROM, to }, 0.899).b
assert.ok(Math.abs(d.cx - (to.x - 3)) <= 1, `drawer ${d.cx}`); assert.ok(Math.abs(p.cx - to.x) <= 1, `paper ${p.cx}`)
// liegt das Ziel näher als 3 Einheiten, bleibt das Blatt beim Start (kein Rückwärtsflug, kein NaN)
const near = shot('drawer', { from: FROM, to: { x: 41, y: 100 } }, 0.899).b; assert.ok(Math.abs(near.cx - FROM.x) <= 1)
const same = shot('drawer', { from: FROM, to: FROM }, 0.4).b; assert.ok(Number.isFinite(same.cx))
})
test('Stempel: grün für OK, rot für Nein, hüpft über dem Punkt und landet darauf', () => {
for (const [name, color, other] of [['stampOk', PAL.green, PAL.red], ['stampNo', PAL.red, PAL.green]]) {
const heights = []
for (let i = 0; i < 12; i++) {
const s = shot(name, { from: FROM }, i * 0.1); assert.ok(s.b.y1 <= FROM.y + 0.01, `${name} bleibt über from`)
assert.ok(s.rects.some((r) => r.fill === color), name); assert.ok(!s.rects.some((r) => r.fill === other), name); heights.push(s.b.y1)
}
assert.ok(Math.min(...heights) < FROM.y - 3, `${name} hüpft`); assert.ok(Math.abs(shot(name, { from: FROM }, 1.199).b.y1 - FROM.y) <= 1, `${name} landet`)
assert.ok(Math.abs(shot(name, { from: FROM }, 0.5).b.cx - FROM.x) <= 1, `${name} mittig`)
}
})
test('Funkeln: Goldpixel wachsen nach außen und bleiben um from', () => {
const early = shot('sparkle', { from: FROM }, 0.05).b, late = shot('sparkle', { from: FROM }, 0.95).b
assert.ok(late.x1 - late.x0 > early.x1 - early.x0 + 6); assert.ok(Math.abs(late.cx - FROM.x) <= 1)
assert.ok(shot('sparkle', { from: FROM }, 0.5).rects.every((r) => r.fill === PAL.gold || r.fill === '#fff3c4'))
})
test('Warnzeichen: blinkt, beginnt sichtbar, steht mit der Unterkante auf from und ist rot', () => {
const on = []
for (let i = 0; i < 30; i++) on.push(shot('alert', { from: FROM }, i * 0.05).rects.length > 0)
assert.equal(on[0], true); assert.ok(on.some((v) => !v)); assert.ok(on.filter(Boolean).length >= 10)
const b = shot('alert', { from: FROM }, 0).b; assert.equal(b.y1, FROM.y); assert.ok(Math.abs(b.cx - FROM.x) <= 1)
assert.ok(shot('alert', { from: FROM }, 0).rects.some((r) => r.fill === PAL.red))
})
test('mit nur to oder nur from werden die Eintragspunkte ersetzt (kein Wurf, kein NaN)', () => {
for (const name of F.FX_NAMES) {
const a = shot(name, { to: { x: 90, y: 50 } }, 0).b, c = shot(name, { from: { x: 90, y: 50 } }, 0).b
assert.ok(a && Number.isFinite(a.cx) && Number.isFinite(a.cy), name); assert.ok(c && Number.isFinite(c.cx) && Number.isFinite(c.cy), name)
}
})
-120
View File
@@ -1,120 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
// Hochhaus-Layout als Daten: 10 Etagen, 19 Plätze, Räume nach Rolle, feste Orte für Effekte
const { buildLayout, homeOf, roomById, floorIndex, fy, cabTop, WORLD, ROOM_META, FLOOR_PITCH, BRIDGE_H } = await loadModule('src/components/workers/engine/layout.js')
const { ROLES, FLOORS, FLOOR_TITLES, ROOM_TITLES } = await loadModule('src/lib/workersRoster.js')
const { TEAM } = await loadModule('src/components/workers/engine/sprites.js')
const layout = buildLayout()
const rooms = (id) => layout.floors.find((f) => f.id === id).rooms.map((r) => [r.id, r.x, r.w])
const role = (workerId, floor, room) => ({ workerId, name: workerId, floor, room, model: 'haiku', role: '', caps: [] })
test('10 Etagen in FLOORS-Reihenfolge mit festen Höhen, Welt 320×256', () => {
assert.deepEqual(layout.floors.map((f) => f.id), [...FLOORS])
assert.deepEqual([WORLD.w, WORLD.h], [320, 256])
assert.deepEqual(layout.floors.map((f) => [f.top, f.floorY]), [[6, 40], [41, 63], [64, 86], [87, 109], [110, 132], [133, 155], [156, 178], [179, 201], [202, 224], [225, 247]])
assert.equal(fy(layout, 3), 95)
assert.equal(cabTop(layout, 3), 88)
})
test('Etagen tragen Index, Titel und Höhe (Brücke 34, sonst 22 plus Bodenlinie = Raster 23)', () => {
assert.equal(BRIDGE_H, 34)
assert.equal(FLOOR_PITCH, 23)
layout.floors.forEach((f, i) => {
assert.equal(f.index, i)
assert.equal(f.title, FLOOR_TITLES[f.id])
assert.equal(f.h, i === 0 ? 34 : 22, f.id)
assert.equal(f.floorY - f.top, f.h, f.id)
})
assert.equal(layout.floors[1].top - layout.floors[0].floorY, 1)
assert.equal(floorIndex(layout, 'tresor'), 8)
assert.equal(floorIndex(layout, 'gibtsnicht'), -1)
})
test('Räume je Etage', () => {
assert.deepEqual(rooms('anfragen'), [['netz', 42, 46], ['kleinanzeigen', 88, 46], ['portale', 134, 46], ['foren', 180, 48]])
assert.deepEqual(rooms('gespraeche').map((r) => r[0]), ['mail', 'forum', 'posteingang', 'abschluss'])
assert.deepEqual(rooms('werkstatt'), [['tisch1', 42, 62], ['tisch2', 104, 62], ['tisch3', 166, 62]])
for (const id of ['bruecke', 'bewertung', 'pruefung', 'freigabe', 'tresor', 'keller']) {
assert.deepEqual(rooms(id).map((r) => [r[1], r[2]]), [[42, 186]], id)
}
})
test('alle Räume füllen die Breite 42..228 lückenlos', () => {
for (const f of layout.floors) {
let x = 42
for (const r of f.rooms) {
assert.equal(r.x, x, `${f.id}/${r.id}`)
x += r.w
}
assert.equal(x, 228, f.id)
}
// andere Raumzahlen teilen gleichmäßig, der letzte Raum nimmt den Rest
const zwei = buildLayout([role('a', 'anfragen', 'netz'), role('b', 'anfragen', 'portale')], ['anfragen'])
assert.deepEqual(zwei.floors[0].rooms.map((r) => [r.x, r.w]), [[42, 93], [135, 93]])
const fuenf = buildLayout(['netz', 'kleinanzeigen', 'portale', 'foren', 'aerzte'].map((room, i) => role(`w${i}`, 'anfragen', room)), ['anfragen'])
assert.deepEqual(fuenf.floors[0].rooms.map((r) => [r.x, r.w]), [[42, 37], [79, 37], [116, 37], [153, 37], [190, 38]])
})
test('19 Worker haben genau einen Platz im eigenen Raum; Freigabe und Tresor ohne Worker', () => {
for (const r of ROLES) {
const h = homeOf(layout, r.workerId)
const room = roomById(layout, r.room)
const wide = r.workerId === 'otto' ? 24 : 12
assert.ok(h.x >= room.x && h.x + wide <= room.x + room.w, r.workerId)
assert.equal(h.floor, FLOORS.indexOf(r.floor))
assert.equal(h.room, r.room)
}
assert.equal(Object.keys(layout.homes).length, 19)
assert.equal(homeOf(layout, 'otto').y, 13)
assert.equal(homeOf(layout, 'kim').y, 49)
assert.deepEqual(layout.floors.filter((f) => f.rooms.some((r) => !r.workerId)).map((f) => f.id), ['freigabe', 'tresor'])
assert.deepEqual(layout.spots, { pult: { x: 135, y: 186 }, tresor: { x: 163, y: 213 }, aussen: { x: 324, y: 212 }, archiv: { x: 148, y: 231 } })
})
test('Platz zentriert in der Raumbreite (Otto 24 breit, sonst 12)', () => {
assert.deepEqual(homeOf(layout, 'otto'), { x: 123, y: 13, floor: 0, room: 'bruecke' })
assert.deepEqual(homeOf(layout, 'rita'), { x: 59, y: 49, floor: 1, room: 'netz' })
assert.equal(homeOf(layout, 'arne').y, 247 - 14)
})
test('Räume tragen Art, Titel, Teamfarbe und Worker aus den Tabellen', () => {
assert.deepEqual(roomById(layout, 'kleinanzeigen'), { id: 'kleinanzeigen', kind: 'inbox', title: ROOM_TITLES.kleinanzeigen, team: 'radar', x: 88, w: 46, workerId: 'kim' })
assert.equal(roomById(layout, 'pult').workerId, null)
assert.equal(roomById(layout, 'tresor').workerId, null)
assert.equal(roomById(layout, 'gibtsnicht'), null)
assert.equal(roomById(layout, 'constructor'), null)
})
test('ROOM_META: genau die 21 Räume aus ROOM_TITLES, bekannte Arten, Teamfarbe aus TEAM', () => {
assert.deepEqual(Object.keys(ROOM_META).sort(), Object.keys(ROOM_TITLES).sort())
const kinds = ['bruecke', 'radar', 'inbox', 'feed', 'board', 'scout', 'waage', 'mail', 'schreiber', 'sortier', 'vertrag', 'tisch', 'pruefung', 'pult', 'tresor', 'archiv']
assert.deepEqual([...new Set(Object.values(ROOM_META).map((m) => m.kind))].sort(), [...kinds].sort())
for (const [id, m] of Object.entries(ROOM_META)) assert.ok(Object.hasOwn(TEAM, m.team), `${id}: ${m.team}`)
const kindTeam = (id) => [ROOM_META[id].kind, ROOM_META[id].team]
assert.deepEqual(['netz', 'kleinanzeigen', 'portale', 'foren'].map(kindTeam), [['radar', 'radar'], ['inbox', 'radar'], ['feed', 'radar'], ['board', 'radar']])
assert.deepEqual(['aerzte', 'handwerk', 'selbst', 'laden'].map(kindTeam), [['scout', 'aerzte'], ['scout', 'handwerk'], ['scout', 'selbst'], ['scout', 'laden']])
assert.deepEqual(['mail', 'forum', 'posteingang', 'abschluss'].map(kindTeam), [['mail', 'freigabe'], ['schreiber', 'freigabe'], ['sortier', 'freigabe'], ['vertrag', 'freigabe']])
assert.deepEqual(['tisch1', 'tisch2', 'tisch3'].map(kindTeam), [['tisch', 'werkstatt'], ['tisch', 'werkstatt'], ['tisch', 'werkstatt']])
assert.deepEqual(['waage', 'pruefung', 'bruecke', 'pult', 'tresor', 'archiv'].map(kindTeam), [['waage', 'bewertung'], ['pruefung', 'pruefung'], ['bruecke', 'freigabe'], ['pult', 'freigabe'], ['tresor', 'tresor'], ['archiv', 'archiv']])
})
test('unbekannte Kennung gibt null, auch bei Namen aus dem Prototyp', () => {
assert.equal(homeOf(layout, 'niemand'), null)
assert.equal(homeOf(layout, 'constructor'), null)
assert.equal(homeOf(layout, '__proto__'), null)
})
test('neue Räume kommen als Daten; unbekannter Raum oder unbekannte Etage wirft', () => {
assert.throws(() => buildLayout([role('x', 'anfragen', 'zz')], ['anfragen']), /Unbekannter Raum: zz/)
assert.throws(() => buildLayout([role('x', 'oben', 'netz')], ['anfragen']), /Unbekannte Etage: oben/)
assert.throws(() => buildLayout([role('x', 'anfragen', 'constructor')], ['anfragen']), /Unbekannter Raum: constructor/)
})
test('Orte für Effekte fehlen (null), wenn die Etage nicht im Layout ist', () => {
const klein = buildLayout([role('x', 'anfragen', 'netz')], ['anfragen'])
assert.deepEqual(klein.spots, { pult: null, tresor: null, aussen: null, archiv: null })
assert.equal(klein.floors.length, 1)
})
-228
View File
@@ -1,228 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
import { makeCtx } from './helpers/fakeCtx.mjs'
// Aufzug: Warteschlange, Zustandsautomat, Abbruch/Reset und Kabine (Quelle: entwuerfe/scene-turm.js)
const { createLift, LIFT } = await loadModule('src/components/workers/engine/lift.js')
const { buildLayout, cabTop } = await loadModule('src/components/workers/engine/layout.js')
const { PAL } = await loadModule('src/components/workers/engine/sprites.js')
const { FLOORS } = await loadModule('src/lib/workersRoster.js')
const layout = buildLayout()
function until(lift, fn, max = 12) { let t = 0; while (!fn() && t < max) { lift.update(0.05); t += 0.05 } return t }
// Schritt für Schritt bis zur Bedingung, mit Sicherung gegen Endlosschleifen bei einem Fehler im Aufzug
function stepUntil(step, fn, max = 400) { for (let i = 0; i < max && !fn(); i++) step(); assert.ok(fn(), 'Bedingung nicht erreicht') }
test('Fahrt 1 → 8: queued, open, boarded, arrived, out; Fahrgast folgt der Kabine; danach idle', () => {
const lift = createLift(layout), a = { x: 0, y: 0, frame: 1 }, req = lift.request(a, 1, 8)
assert.equal(req.st, 'queued')
let t = until(lift, () => req.st === 'open'); assert.ok(lift.at(1)); assert.equal(lift.state, 'boarding')
req.st = 'boarded'; t += until(lift, () => req.st === 'arrived'); assert.ok(lift.at(8)); assert.equal(a.y, cabTop(layout, 8) + 7); assert.equal(a.x, LIFT.cabWx)
assert.ok(t < 12); req.st = 'out'; lift.update(0.05); assert.equal(lift.state, 'idle')
})
test('FIFO: zweite Anfrage wartet bis die erste ausgestiegen ist', () => {
const lift = createLift(layout), r1 = lift.request({}, 1, 4), r2 = lift.request({}, 5, 2)
until(lift, () => r1.st === 'open'); r1.st = 'boarded'; until(lift, () => r1.st === 'arrived'); assert.equal(r2.st, 'queued')
r1.st = 'out'; until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open')
})
test('cancel und reset geben die Kabine frei (kein Festhängen)', () => {
const lift = createLift(layout), a = {}, r1 = lift.request(a, 7, 1), r2 = lift.request({}, 2, 3)
lift.update(0.05); lift.cancel(a); assert.equal(r1.st, 'cancelled'); until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open')
lift.reset(); assert.equal(r2.st, 'cancelled'); assert.equal(lift.state, 'idle')
})
// ---- Ergänzungen: Verhalten, das die drei Tests oben nicht berühren ----
test('LIFT-Werte wie vereinbart und cabH stimmt mit der Kabinenhöhe des Layouts überein', () => {
assert.deepEqual({ ...LIFT }, { cabWx: 22, exitX: 50, slot0: 52, slotStep: 14, speed: 110, cabH: 21 })
layout.floors.forEach((f, i) => assert.equal(f.floorY - cabTop(layout, i), LIFT.cabH))
})
test('Start: Kabine steht auf Etage 2 im Leerlauf; at() gilt bis 1,5 Einheiten Abstand und ist bei ungültigem Index false', () => {
const lift = createLift(layout)
assert.equal(lift.y, cabTop(layout, 2)); assert.equal(lift.state, 'idle')
layout.floors.forEach((f, i) => assert.equal(lift.at(i), i === 2, `at(${i})`))
lift.y = cabTop(layout, 2) + 1.4; assert.ok(lift.at(2))
lift.y = cabTop(layout, 2) - 1.4; assert.ok(lift.at(2))
lift.y = cabTop(layout, 2) + 1.5; assert.ok(!lift.at(2))
for (const i of [-1, 10, 99, 2.5, NaN, '2', undefined, null]) assert.equal(lift.at(i), false)
})
test('Layout mit nur zwei Etagen: Startetage ist die letzte vorhandene (kein Wurf)', () => {
const small = buildLayout([], FLOORS.slice(0, 2)), lift = createLift(small)
assert.equal(lift.y, cabTop(small, 1)); assert.ok(lift.at(1))
})
test('Zustandsfolge einer Fahrt; das Schließen dauert 0,3 s', () => {
const lift = createLift(layout), a = { x: 0, y: 0, frame: 1 }, req = lift.request(a, 4, 6), seen = [lift.state]
const step = () => { lift.update(0.05); if (seen.at(-1) !== lift.state) seen.push(lift.state) }
stepUntil(step, () => req.st === 'open')
assert.equal(lift.state, 'boarding')
req.st = 'boarded'
step(); assert.equal(lift.state, 'closing')
for (let i = 0; i < 5; i++) step()
assert.equal(lift.state, 'closing') // 0,25 s Tür
step(); step(); assert.equal(lift.state, 'riding') // 0,35 s
stepUntil(step, () => req.st === 'arrived')
assert.equal(lift.state, 'unloading')
step(); assert.equal(lift.state, 'unloading') // wartet auf den Fahrgast
req.st = 'out'; step()
assert.deepEqual(seen, ['idle', 'toPick', 'boarding', 'closing', 'riding', 'unloading', 'idle'])
assert.equal(req.st, 'out')
})
test('Kabine fährt mit 110 Einheiten pro Sekunde, nach oben und nach unten', () => {
const lift = createLift(layout), y0 = lift.y
lift.request({}, 8, 1)
for (let i = 0; i < 20; i++) lift.update(0.05) // 1 s
assert.ok(Math.abs(lift.y - (y0 + 110)) < 1e-9, `nach 1 s ${lift.y - y0}`)
const up = createLift(layout)
up.request({}, 0, 1)
for (let i = 0; i < 4; i++) up.update(0.05) // 0,2 s
assert.ok(Math.abs(up.y - (y0 - 22)) < 1e-9, `nach 0,2 s ${up.y - y0}`)
for (let i = 0; i < 6; i++) up.update(0.05) // Etage 0 liegt 46 Einheiten höher: erreicht, kein Überschwingen
assert.equal(up.y, cabTop(layout, 0)); assert.equal(up.state, 'boarding')
})
test('Fahrgast bleibt bis zum Schließen unberührt, folgt dann der Kabine und wird beim Aussteigen losgelassen', () => {
const lift = createLift(layout), a = { x: 12, y: 34, frame: 1 }, req = lift.request(a, 5, 1)
until(lift, () => req.st === 'open')
assert.deepEqual(a, { x: 12, y: 34, frame: 1 }) // Anfahren und Einsteigen: die Regie bewegt ihn
a.x = LIFT.cabWx; a.y = lift.y + 7; a.frame = 0 // die Regie hat ihn in die Kabine gestellt (Schritt 0, Blick nach vorn)
req.st = 'boarded'
let followed = 0
stepUntil(() => {
lift.update(0.05)
if (lift.state === 'closing' || lift.state === 'riding') {
if (a.y !== lift.y + 7 || a.x !== LIFT.cabWx || a.frame !== 0) assert.fail(`Fahrgast ${a.x}/${a.y}/${a.frame} bei Kabine ${lift.y}`)
followed++
}
}, () => req.st === 'arrived')
assert.ok(followed > 10, `folgte nur ${followed}-mal`)
assert.equal(a.y, cabTop(layout, 1) + 7)
a.x = 77; a.y = 66; a.frame = 1 // Aussteigen: die Regie setzt ihn selbst
lift.update(0.05); lift.update(0.05)
assert.deepEqual(a, { x: 77, y: 66, frame: 1 })
})
test('Fahrt auf derselben Etage öffnet sofort und schließt ab', () => {
const lift = createLift(layout), req = lift.request({}, 2, 2)
lift.update(0.05); assert.equal(req.st, 'open')
req.st = 'boarded'; until(lift, () => req.st === 'arrived'); assert.equal(req.st, 'arrived')
req.st = 'out'; lift.update(0.05); assert.equal(lift.state, 'idle')
})
test('cancel während der Fahrt: Kabine frei, Fahrgast wird nicht mehr bewegt, nächste Anfrage kommt dran', () => {
const lift = createLift(layout), a = { x: LIFT.cabWx, y: 0, frame: 0 }, r1 = lift.request(a, 2, 9), r2 = lift.request({}, 3, 4)
until(lift, () => r1.st === 'open'); r1.st = 'boarded'
until(lift, () => lift.state === 'riding'); lift.update(0.05)
lift.cancel(a)
assert.equal(r1.st, 'cancelled'); assert.equal(lift.state, 'idle')
const before = { ...a }
lift.update(0.05); assert.deepEqual(a, before)
until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open'); assert.equal(lift.state, 'boarding')
assert.deepEqual(a, before)
})
test('cancel einer wartenden Anfrage: nie bedient, die übrigen bleiben in Reihenfolge; mehrere Anfragen eines Agenten fallen alle', () => {
const lift = createLift(layout), a2 = {}
const r1 = lift.request({}, 1, 4), r2 = lift.request(a2, 5, 2), r3 = lift.request({}, 6, 3), r4 = lift.request(a2, 7, 3)
lift.cancel(a2)
assert.deepEqual([r1.st, r2.st, r3.st, r4.st], ['queued', 'cancelled', 'queued', 'cancelled'])
until(lift, () => r1.st === 'open'); r1.st = 'boarded'; until(lift, () => r1.st === 'arrived'); r1.st = 'out'
const seen = new Set()
until(lift, () => { seen.add(r2.st).add(r4.st); return r3.st === 'open' })
assert.equal(r3.st, 'open')
assert.deepEqual([...seen], ['cancelled'])
})
test('cancel eines unbekannten oder bereits ausgestiegenen Agenten ändert nichts', () => {
const lift = createLift(layout), a = {}, r = lift.request(a, 2, 3)
until(lift, () => r.st === 'open'); r.st = 'boarded'; until(lift, () => r.st === 'arrived'); r.st = 'out'
lift.cancel({}); lift.cancel(undefined); assert.equal(r.st, 'out'); assert.equal(lift.state, 'unloading')
lift.cancel(a); assert.equal(r.st, 'out') // ausgestiegen bleibt ausgestiegen
lift.update(0.05); assert.equal(lift.state, 'idle')
})
test('update überspringt von außen abgebrochene Anfragen (wartend und laufend)', () => {
const lift = createLift(layout)
const r1 = lift.request({}, 1, 4), r2 = lift.request({}, 5, 2), r3 = lift.request({}, 6, 3)
r2.st = 'cancelled'
until(lift, () => r1.st === 'open')
r1.st = 'cancelled' // laufende Anfrage von außen abgebrochen: kein Festhängen im Zustand boarding
lift.update(0.05)
assert.equal(lift.state, 'toPick')
until(lift, () => r3.st === 'open'); assert.equal(r3.st, 'open')
assert.equal(r2.st, 'cancelled'); assert.equal(r1.st, 'cancelled')
})
test('reset bricht Wartende und die laufende Anfrage ab, ausgestiegene bleiben; danach fährt der Aufzug wieder', () => {
const lift = createLift(layout), r1 = lift.request({}, 1, 4), r2 = lift.request({}, 5, 2), r3 = lift.request({}, 6, 3)
until(lift, () => r1.st === 'open'); r1.st = 'boarded'; until(lift, () => r1.st === 'arrived'); r1.st = 'out'; lift.update(0.05)
until(lift, () => r2.st === 'open')
lift.reset()
assert.deepEqual([r1.st, r2.st, r3.st], ['out', 'cancelled', 'cancelled']); assert.equal(lift.state, 'idle')
lift.update(0.05); assert.equal(lift.state, 'idle') // Warteschlange ist leer
const r4 = lift.request({}, 3, 5); until(lift, () => r4.st === 'open'); assert.equal(r4.st, 'open')
})
test('ungültige Anfragen (Etage, Agent) kommen abgebrochen zurück und blockieren nichts', () => {
const lift = createLift(layout)
for (const [a, from, to] of [[{}, -1, 3], [{}, 1, 10], [{}, 1.5, 2], [{}, '1', 2], [{}, 1], [{}, NaN, 2], [null, 1, 2], [undefined, 1, 2], ['x', 1, 2]]) {
const r = lift.request(a, from, to)
assert.equal(r.st, 'cancelled', JSON.stringify([from, to]))
assert.equal(r.a, a)
}
lift.update(0.05); assert.equal(lift.state, 'idle')
const ok = lift.request({}, 1, 2); until(lift, () => ok.st === 'open'); assert.equal(ok.st, 'open')
})
test('unbrauchbares dt (0, negativ, NaN, Infinity, kein Zahlentyp) ändert nichts', () => {
const lift = createLift(layout), r = lift.request({}, 8, 1), y0 = lift.y
for (const dt of [0, -1, NaN, Infinity, undefined, '0.05']) lift.update(dt)
assert.equal(lift.state, 'idle'); assert.equal(lift.y, y0); assert.equal(r.st, 'queued')
})
// ---- Kabine zeichnen (Raster 3, kein setGrid: logische Einheit = 3 Geräte-Pixel) ----
const K = 3
const rect = (x, y, w, h, fill) => ({ x: x * K, y: y * K, w: w * K, h: h * K, fill })
test('drawBack: Seil von oben bis zur Kabine, Rückwand mit Handlauf und Fußleiste', () => {
const lift = createLift(layout), { ctx, rects } = makeCtx(), cy = cabTop(layout, 2)
lift.drawBack(ctx)
assert.deepEqual(rects, [
rect(28, 9, 1, cy - 9, PAL.metal2),
rect(18, cy, 20, 21, '#f1f3f8'),
rect(19, cy + 11, 18, 1, '#cfd5df'),
rect(19, cy + 18, 18, 2, '#e1e5ec'),
])
})
test('drawFront: Glasschleier, Rahmen, Lampe und Aufhängung; save/restore gleichen sich aus', () => {
const lift = createLift(layout), { ctx, rects, counts, alphas } = makeCtx(), cy = cabTop(layout, 2)
lift.drawFront(ctx)
assert.deepEqual(rects, [
rect(19, cy + 1, 18, 19, '#ffffff'),
rect(18, cy, 20, 1, PAL.metal2),
rect(18, cy + 20, 20, 1, PAL.metal2),
rect(18, cy, 1, 21, PAL.metal2),
rect(37, cy, 1, 21, PAL.metal2),
rect(24, cy + 1, 8, 1, '#ffe9a8'),
rect(26, cy - 2, 4, 2, PAL.metal2),
])
assert.deepEqual(alphas, [0.18]); assert.equal(counts.save, 1); assert.equal(counts.restore, 1)
})
test('Kabine wird an der gerundeten Fahrposition gezeichnet und bleibt im Schacht (x 18..38)', () => {
const lift = createLift(layout)
lift.request({}, 8, 1)
lift.update(0.05); lift.update(0.05); lift.update(0.05)
assert.ok(!Number.isInteger(lift.y), `Testvoraussetzung: Kabine zwischen Rastern (y ${lift.y})`)
const cy = Math.round(lift.y), back = makeCtx(), front = makeCtx()
lift.drawBack(back.ctx); lift.drawFront(front.ctx)
assert.deepEqual(back.rects[1], rect(18, cy, 20, 21, '#f1f3f8'))
assert.deepEqual(back.rects[0], rect(28, 9, 1, cy - 9, PAL.metal2))
assert.deepEqual(front.rects[1], rect(18, cy, 20, 1, PAL.metal2))
for (const r of [...back.rects.slice(1), ...front.rects]) assert.ok(r.x >= 18 * K && r.x + r.w <= 38 * K, JSON.stringify(r))
})
-82
View File
@@ -1,82 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
import { makeCtx } from './helpers/fakeCtx.mjs'
// Engine-Requisiten: Möbel, Briefkasten, Tresor, Waage, Schild und die 7×7-Branchen-Icons
const P = await loadModule('src/components/workers/engine/props.js')
const { PAL } = await loadModule('src/components/workers/engine/sprites.js')
const { SEARCH_ROOMS } = await loadModule('src/lib/workersRoster.js')
test('alle Requisiten zeichnen ohne Wurf und mindestens ein Rechteck', () => {
const calls = { desk: (c) => P.drawDesk(c, 0, 0, 18, '#f00'), monitor: (c) => P.drawMonitor(c, 0, 0, 1, '#7fb2ff'), cabinet: (c) => P.drawCabinet(c, 0, 0, 3), plant: (c) => P.drawPlant(c, 0, 0),
coin: (c) => P.drawCoin(c, 0, 0, 0.3), trophy: (c) => P.drawTrophy(c, 0, 0), stampOk: (c) => P.drawStamp(c, 0, 0, true), stampNo: (c) => P.drawStamp(c, 0, 0, false),
radar: (c) => P.drawRadar(c, 0, 0, 1), mailbox: (c) => P.drawMailbox(c, 0, 0, 2), vault: (c) => P.drawVault(c, 0, 0, 1), scale: (c) => P.drawScale(c, 0, 0, 2), tray: (c) => P.drawTray(c, 0, 0, true) }
for (const [name, fn] of Object.entries(calls)) { const { ctx, rects } = makeCtx(); fn(ctx); assert.ok(rects.length >= 1, name) }
})
test('Briefkasten-Abzeichen nur bei count > 0; Monitor aus zeigt keine Inhaltszeilen', () => {
const red = (n) => { const { ctx, rects } = makeCtx(); P.drawMailbox(ctx, 0, 0, n); return rects.filter((r) => r.fill === PAL.red).length }
assert.equal(red(0), 0); assert.ok(red(3) >= 1)
const lines = (on) => { const { ctx, rects } = makeCtx(); P.drawMonitor(ctx, 0, 0, 1, '#abcdef', on); return rects.filter((r) => r.fill === '#abcdef').length }
assert.ok(lines(true) >= 2); assert.equal(lines(false), 0)
})
test('ICONS: genau die 8 Suchräume, je 7 Zeilen à 7 Zeichen; drawSign schreibt den Text', () => {
assert.deepEqual(Object.keys(P.ICONS).sort(), ['aerzte', 'foren', 'handwerk', 'kleinanzeigen', 'laden', 'netz', 'portale', 'selbst'])
for (const [k, rows] of Object.entries(P.ICONS)) { assert.equal(rows.length, 7, k); for (const r of rows) assert.match(r, /^[.xa]{7}$/, k) }
const { ctx, texts, rects } = makeCtx(); P.drawSign(ctx, 0, 0, 40, 'noch nicht angeschlossen'); assert.equal(texts[0].text, 'noch nicht angeschlossen'); assert.ok(rects.length >= 2)
const t = makeCtx(); P.drawIcon(t.ctx, 'aerzte', 0, 0, '#3fb6b2'); assert.ok(t.rects.length >= 5)
})
// ---- Ergänzungen: Verhalten, das die drei Tests oben nicht berühren ----
test('ICONS: Schlüssel gleich SEARCH_ROOMS, eingefroren, jedes Icon hat Linien und die Zeichen stehen nur in .xa', () => {
assert.deepEqual([...SEARCH_ROOMS].sort(), Object.keys(P.ICONS).sort())
assert.ok(Object.isFrozen(P.ICONS))
for (const [k, rows] of Object.entries(P.ICONS)) {
assert.ok(Object.isFrozen(rows), k)
assert.ok(rows.join('').includes('x'), k)
}
})
test('drawIcon: x in der Farbe, a in Gold, nichts außerhalb von 7 × 7; unbekannter Name zeichnet nichts und wirft nicht', () => {
const { ctx, rects } = makeCtx(); P.drawIcon(ctx, 'handwerk', 10, 20, '#3fb6b2')
assert.ok(rects.some((r) => r.fill === '#3fb6b2')); assert.ok(rects.some((r) => r.fill === PAL.gold))
assert.ok(rects.every((r) => r.fill === '#3fb6b2' || r.fill === PAL.gold))
assert.ok(rects.every((r) => r.x >= 30 && r.x + r.w <= 30 + 21 && r.y >= 60 && r.y + r.h <= 60 + 21))
for (const name of ['gibt-es-nicht', 'constructor', '__proto__', 'toString', undefined, null]) {
const e = makeCtx(); assert.doesNotThrow(() => P.drawIcon(e.ctx, name, 0, 0, '#000'), String(name)); assert.equal(e.rects.length, 0, String(name))
}
})
test('drawIcon: jedes der acht Icons zeichnet sichtbar (mindestens 5 Rechtecke)', () => {
for (const name of Object.keys(P.ICONS)) { const { ctx, rects } = makeCtx(); P.drawIcon(ctx, name, 0, 0, '#123456'); assert.ok(rects.length >= 5, name) }
})
test('drawMonitor: Standard ist an; aus hat kein Blinken, an blinkt mit t', () => {
const draw = (t, on) => { const { ctx, rects } = makeCtx(); if (on === undefined) P.drawMonitor(ctx, 0, 0, t, '#abcdef'); else P.drawMonitor(ctx, 0, 0, t, '#abcdef', on); return JSON.stringify(rects) }
assert.equal(draw(0.2), draw(0.2, true))
assert.notEqual(draw(0.2, true), draw(0.7, true))
assert.equal(draw(0.2, false), draw(0.7, false))
const off = makeCtx(); P.drawMonitor(off.ctx, 0, 0, 1, '#abcdef', false); assert.ok(off.rects.length >= 2) // Rahmen und Schirm bleiben
})
test('drawTray: voll zeigt Papier über dem Rand, beide bleiben in 10 × 4', () => {
const draw = (filled) => { const { ctx, rects } = makeCtx(); P.drawTray(ctx, 5, 7, filled); return rects }
const full = draw(true); const empty = draw(false)
assert.ok(full.some((r) => r.fill === PAL.w)); assert.ok(!empty.some((r) => r.fill === PAL.w))
for (const rs of [full, empty]) assert.ok(rs.every((r) => r.x >= 15 && r.x + r.w <= 15 + 30 && r.y >= 21 && r.y + r.h <= 21 + 12))
assert.equal(Math.max(...full.map((r) => r.x + r.w)) - Math.min(...full.map((r) => r.x)), 30)
})
test('drawSign: dunkler Rand, weiße Fläche, Text mittig im Schild', () => {
const { ctx, rects, texts } = makeCtx(); P.drawSign(ctx, 4, 6, 40, 'Ablage')
assert.equal(rects[0].fill, PAL.o); assert.ok(rects.some((r) => r.fill === PAL.w))
const border = rects[0]; assert.deepEqual([border.x, border.y, border.w], [12, 18, 120])
assert.ok(rects.slice(1).every((r) => r.x > border.x && r.x + r.w < border.x + border.w && r.y > border.y && r.y + r.h < border.y + border.h))
assert.equal(texts.length, 1); assert.equal(texts[0].x, (4 + 20) * 3)
assert.ok(texts[0].y > border.y && texts[0].y < border.y + border.h)
})
test('drawStamp: ok grün, nein rot; drawCoin dreht sich mit t; drawScale kippt mit tilt', () => {
const fills = (fn) => { const { ctx, rects } = makeCtx(); fn(ctx); return new Set(rects.map((r) => r.fill)) }
assert.ok(fills((c) => P.drawStamp(c, 0, 0, true)).has(PAL.green)); assert.ok(!fills((c) => P.drawStamp(c, 0, 0, true)).has(PAL.red))
assert.ok(fills((c) => P.drawStamp(c, 0, 0, false)).has(PAL.red)); assert.ok(!fills((c) => P.drawStamp(c, 0, 0, false)).has(PAL.green))
const coinW = (t) => { const { ctx, rects } = makeCtx(); P.drawCoin(ctx, 0, 0, t); return rects[0].w }
assert.notEqual(coinW(0), coinW(1 / 6))
const pans = (tilt) => { const { ctx, rects } = makeCtx(); P.drawScale(ctx, 0, 0, tilt); return rects.filter((r) => r.w === 15 && r.h === 6).map((r) => r.y) }
assert.equal(pans(0).length, 2); assert.notDeepEqual(pans(2), pans(-2))
})
-336
View File
@@ -1,336 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
import { makeCtx } from './helpers/fakeCtx.mjs'
// Räume nach kind, Pokal-Seiten, graue Räume und Gebäudehülle (Quelle: entwuerfe/scene-turm.js Z. 99-314)
const R = await loadModule('src/components/workers/engine/rooms.js')
const B = await loadModule('src/components/workers/engine/building.js')
const { buildLayout, ROOM_META } = await loadModule('src/components/workers/engine/layout.js')
const { PAL, TEAM } = await loadModule('src/components/workers/engine/sprites.js')
const { FLOOR_TITLES, ROOM_TITLES } = await loadModule('src/lib/workersRoster.js')
const layout = buildLayout()
const draw = (look, t = 1) => {
const m = makeCtx()
R.drawRooms(m.ctx, layout, t, look)
return m
}
const SIGN = 'noch nicht angeschlossen'
const looks = (patch = {}) => Object.assign(R.createLook(), patch)
const count = (rects, fill) => rects.filter((r) => r.fill === fill).length
// Pokale sind die 7 × 3 großen Goldflächen (k = 3: 21 × 9 Geräte-Pixel)
const trophies = (look) => draw(look).rects.filter((r) => r.fill === PAL.gold && r.w === 21 && r.h === 9).length
test('trophyPaging: 8 je Seite, Seite wird geklemmt', () => {
assert.deepEqual(R.trophyPaging(0, 0), { shown: 0, page: 0, pages: 1, from: 0 })
assert.deepEqual(R.trophyPaging(8, 0), { shown: 8, page: 0, pages: 1, from: 0 })
assert.deepEqual(R.trophyPaging(9, 1), { shown: 1, page: 1, pages: 2, from: 8 })
assert.deepEqual(R.trophyPaging(9, 5), { shown: 1, page: 1, pages: 2, from: 8 })
assert.deepEqual(R.trophyPaging(25, -1), { shown: 8, page: 0, pages: 4, from: 0 })
})
test('alle 21 Räume zeichnen, jeder Titel erscheint', () => {
const { texts, rects } = draw(R.createLook())
assert.ok(rects.length > 300)
for (const title of Object.values(ROOM_TITLES)) assert.ok(texts.some((x) => x.text === title), title)
})
test('graue Räume tragen das Schild, sonst keins (Review Focus 4)', () => {
const look = R.createLook()
look.greyRooms = new Set(['kleinanzeigen', 'portale'])
assert.equal(draw(look).texts.filter((x) => x.text === SIGN).length, 2)
assert.equal(draw(R.createLook()).texts.filter((x) => x.text === SIGN).length, 0)
})
test('Pult-Zahl, Pokal- und Bot-Lead-Zähler', () => {
const look = R.createLook()
look.pultOpen = 7
look.dealsMonth = 9
look.botLeads = 112
const t = draw(look).texts.map((x) => x.text)
assert.ok(t.includes('7'))
assert.ok(t.includes('9 Deals'))
assert.ok(t.includes('112 Bot-Leads'))
assert.ok(!draw(R.createLook()).texts.some((x) => x.text === '7'))
})
test('Gebäude: Etagenschilder, Lampen, Pausenschleier', () => {
const m = makeCtx()
B.drawTower(m.ctx, layout, 1, { at: (i) => i === 3 })
for (const title of Object.values(FLOOR_TITLES)) assert.ok(m.texts.some((x) => x.text === title), title)
const on = makeCtx()
const look = R.createLook()
look.paused = true
B.drawPauseVeil(on.ctx, look)
assert.ok(on.texts.some((x) => x.text === 'Pausiert'))
const off = makeCtx()
B.drawPauseVeil(off.ctx, R.createLook())
assert.equal(off.texts.length, 0)
B.drawOutside(makeCtx().ctx)
})
// ---- Ergänzungen: Verhalten, das die fünf Tests oben nicht berühren ----
test('createLook: Ausgangszustand ohne gemeinsame Mengen zwischen zwei Aufrufen', () => {
const a = R.createLook()
const b = R.createLook()
assert.ok(a.greyRooms instanceof Set && a.busyRooms instanceof Set)
assert.equal(a.greyRooms.size + a.busyRooms.size, 0)
assert.deepEqual({ ...a, greyRooms: 0, busyRooms: 0 }, { greyRooms: 0, busyRooms: 0, pultOpen: 0, dealsMonth: 0, botLeads: 0, trophyPage: 0, pageT: 0, trophyPop: 0, vaultFlash: 0, paused: false })
a.busyRooms.add('netz')
assert.equal(b.busyRooms.size, 0)
})
test('ROOM_DRAWERS: eine Zeichnung für jede Raumart des Layouts, eingefroren', () => {
const kinds = new Set(Object.values(ROOM_META).map((m) => m.kind))
assert.equal(kinds.size, 16)
for (const kind of kinds) assert.equal(typeof R.ROOM_DRAWERS[kind], 'function', kind)
assert.ok(Object.isFrozen(R.ROOM_DRAWERS))
})
test('jeder Raum zeichnet sichtbaren Inhalt über Tint und Titel hinaus (kein leerer Raum)', () => {
// Rechtecke je Raum zählen: Raum für Raum in ein eigenes Layout mit nur dieser Etage
for (const floor of layout.floors) {
for (const room of floor.rooms) {
const single = { ...layout, floors: [{ ...floor, rooms: [room] }] }
const m = makeCtx()
R.drawRooms(m.ctx, single, 1, R.createLook())
assert.ok(m.rects.length >= 5, room.id)
}
}
})
test('trophyPaging: ungültige Werte fallen auf eine leere erste Seite zurück', () => {
const empty = { shown: 0, page: 0, pages: 1, from: 0 }
assert.deepEqual(R.trophyPaging(NaN, NaN), empty)
assert.deepEqual(R.trophyPaging(undefined, undefined), empty)
assert.deepEqual(R.trophyPaging(-4, 2), empty)
assert.deepEqual(R.trophyPaging(16, 1), { shown: 8, page: 1, pages: 2, from: 8 })
assert.deepEqual(R.trophyPaging(5, 0, 2), { shown: 2, page: 0, pages: 3, from: 0 })
})
test('Pokalbrett zeigt die gewählte Seite; der neueste Pokal blinkt bei trophyPop', () => {
assert.equal(trophies(looks()), 0)
assert.equal(trophies(looks({ dealsMonth: 3 })), 3)
assert.equal(trophies(looks({ dealsMonth: 20 })), 8)
assert.equal(trophies(looks({ dealsMonth: 9, trophyPage: 1 })), 1)
assert.equal(trophies(looks({ dealsMonth: 9, trophyPage: 7 })), 1)
// floor(0.25 * 10) = 2 -> gerade: sichtbar; floor(0.15 * 10) = 1 -> ungerade: der neueste Pokal fehlt
assert.equal(trophies(looks({ dealsMonth: 3, trophyPop: 0.25 })), 3)
assert.equal(trophies(looks({ dealsMonth: 3, trophyPop: 0.15 })), 2)
})
test('Tresor blitzt Gold bei vaultFlash, sonst nicht', () => {
const gold = (look) => draw(look).alphas.filter((a) => a > 0.1).length
assert.ok(gold(looks({ vaultFlash: 0.7 })) > gold(looks()))
})
test('Pult-Zahl: 0 zeigt keine Zahl, ab 100 steht 99+', () => {
const nums = (n) => draw(looks({ pultOpen: n })).texts.map((x) => x.text).filter((x) => /^\d+\+?$/.test(x))
assert.deepEqual(nums(0), [])
assert.deepEqual(nums(120), ['99+'])
assert.deepEqual(nums(99), ['99'])
})
test('Zähler zeigen immer ihre Zahl, auch 0', () => {
const t = draw(R.createLook()).texts.map((x) => x.text)
assert.ok(t.includes('0 Deals'))
assert.ok(t.includes('0 Bot-Leads'))
})
test('Monitore laufen nur in Räumen aus busyRooms', () => {
const lines = (look) => count(draw(look).rects, TEAM.aerzte)
const idle = lines(looks())
const busy = lines(looks({ busyRooms: new Set(['aerzte']) }))
assert.ok(busy > idle)
// ein anderer Raum arbeitet: Ärzte & Praxen bleibt dunkel
assert.equal(lines(looks({ busyRooms: new Set(['handwerk']) })), idle)
})
test('graue Räume: Monitor aus trotz busyRooms, pult und tresor werden nie grau', () => {
const lines = (look) => count(draw(look).rects, TEAM.aerzte)
const grey = looks({ greyRooms: new Set(['aerzte']), busyRooms: new Set(['aerzte']) })
const greyIdle = looks({ greyRooms: new Set(['aerzte']) })
assert.equal(lines(grey), lines(greyIdle))
const never = looks({ greyRooms: new Set(['pult', 'tresor', 'gibt-es-nicht']) })
assert.equal(draw(never).texts.filter((x) => x.text === SIGN).length, 0)
})
test('Schild steht innerhalb des grauen Raums und in der Etage seiner Figur', () => {
const look = looks({ greyRooms: new Set(['kleinanzeigen']) })
const m = draw(look)
const sign = m.texts.find((x) => x.text === SIGN)
const floor = layout.floors.find((f) => f.id === 'anfragen')
const room = floor.rooms.find((r) => r.id === 'kleinanzeigen')
// Mitte des Raums (k = 3, align center), Zeile innerhalb der Etage
assert.equal(sign.x, Math.round((room.x + room.w / 2) * 3))
assert.ok(sign.y >= floor.top * 3 && sign.y < floor.floorY * 3)
})
test('unbekannte Raumart zeichnet nur Titel und Tint, ohne Wurf', () => {
const odd = { ...layout, floors: layout.floors.map((f) => ({ ...f, rooms: f.rooms.map((r) => ({ ...r, kind: 'gibt-es-nicht' })) })) }
const m = makeCtx()
assert.doesNotThrow(() => R.drawRooms(m.ctx, odd, 1, R.createLook()))
assert.ok(m.texts.some((x) => x.text === ROOM_TITLES.netz))
})
test('drawRooms hält save/restore im Gleichgewicht', () => {
const m = draw(looks({ greyRooms: new Set(['netz']), pultOpen: 3, dealsMonth: 2 }))
assert.equal(m.counts.save, m.counts.restore)
})
test('Gebäude: Bodenlinie liegt auf dem Boden der Tresor-Etage (y 224)', () => {
const tresor = layout.floors.find((f) => f.id === 'tresor')
assert.equal(tresor.floorY, 224)
const m = makeCtx()
B.drawOutside(m.ctx)
// k = 3: volle Breite 960, eine logische Einheit hoch, auf y 672
assert.ok(m.rects.some((r) => r.x === 0 && r.y === 224 * 3 && r.w === 960 && r.h === 3))
// Erde unter der Bodenlinie, bis zum Weltrand
assert.ok(m.rects.some((r) => r.y === 225 * 3 && r.h === (256 - 225) * 3))
})
test('Gebäude: Lampe der Etage mit Kabine leuchtet grün, keine ohne Kabine', () => {
const green = (at) => {
const m = makeCtx()
B.drawTower(m.ctx, layout, 1, { at })
return count(m.rects, PAL.green)
}
const none = green(() => false)
assert.equal(green((i) => i === 3), none + 1)
assert.equal(green((i) => i === 0 || i === 9), none + 2)
})
test('Gebäude: Etagenschilder rechts außen bei x 234, in der Zeile ihrer Etage', () => {
const m = makeCtx()
B.drawTower(m.ctx, layout, 1, { at: () => false })
for (const floor of layout.floors) {
const sign = m.texts.find((x) => x.text === floor.title)
assert.equal(sign.x, 234 * 3, floor.id)
assert.ok(sign.y >= floor.top * 3 && sign.y < floor.floorY * 3, floor.id)
}
})
test('Gebäude: Hülle reicht bis zum Fundament der letzten Etage', () => {
const m = makeCtx()
B.drawTower(m.ctx, layout, 1, { at: () => false })
const last = layout.floors[layout.floors.length - 1]
const bottom = Math.max(...m.rects.map((r) => (r.y + r.h) / 3))
assert.equal(bottom, last.floorY + 2)
assert.ok(m.rects.every((r) => r.x >= 0 && (r.x + r.w) / 3 <= 320))
})
test('Pausenschleier: 45 % Weiß über der ganzen Welt, Aufrufe gleichen sich aus', () => {
const m = makeCtx()
B.drawPauseVeil(m.ctx, looks({ paused: true }))
assert.ok(m.alphas.includes(0.45))
const veil = m.rects.find((r) => r.fill === PAL.w)
assert.ok(veil.x <= 0 && veil.y <= 0 && veil.x + veil.w >= 960 && veil.y + veil.h >= 768)
assert.equal(m.counts.save, m.counts.restore)
const off = makeCtx()
B.drawPauseVeil(off.ctx, R.createLook())
assert.equal(off.rects.length, 0)
})
test('Gebäude: Layout ohne Etagen zeichnet nichts und wirft nicht', () => {
const m = makeCtx()
assert.doesNotThrow(() => B.drawTower(m.ctx, { floors: [], homes: {}, spots: {} }, 1, { at: () => false }))
assert.doesNotThrow(() => R.drawRooms(m.ctx, { floors: [], homes: {}, spots: {} }, 1, R.createLook()))
})
// ---- Fix-Runde 1: Zeichnung und Effektorte, graue Räume ----
// Rechtecke der Raumzeichnung, die den Mittelpunkt der Einheitszelle (x, y) überdecken; die Tönung, die den ganzen Raum füllt, zählt nicht
const hitsAt = (floorId, spot, look = R.createLook()) => {
const floor = layout.floors.find((f) => f.id === floorId)
const full = Math.max(...floor.rooms.map((r) => r.w)) * 3
const m = makeCtx()
R.drawRooms(m.ctx, { ...layout, floors: [floor] }, 1, look)
const cx = spot.x * 3 + 1
const cy = spot.y * 3 + 1
return m.rects.filter((r) => r.w < full && cx >= r.x && cx < r.x + r.w && cy >= r.y && cy < r.y + r.h)
}
test('Zeichnung trifft den Spot: Briefkasten am Pult, Spitze genau bei spots.pult', () => {
const spot = layout.spots.pult
assert.ok(hitsAt('freigabe', spot).some((r) => r.fill === TEAM.freigabe), 'Briefkasten unter dem Spot')
// Spitze: eine Zeile darüber ist kein Briefkasten mehr
assert.ok(!hitsAt('freigabe', { x: spot.x, y: spot.y - 1 }).some((r) => r.fill === TEAM.freigabe))
})
test('Zeichnung trifft den Spot: Tresortür in der Mitte von spots.tresor', () => {
const hit = hitsAt('tresor', layout.spots.tresor)
assert.ok(hit.some((r) => r.fill === PAL.metal2 && r.w === 22 * 3 && r.h === 20 * 3), 'Tresorkörper unter dem Spot')
// die 6 × 6 große Türplatte (x + 8 ... x + 14) umschließt den Spot
assert.ok(hit.some((r) => r.fill === PAL.metal && r.w === 6 * 3 && r.h === 6 * 3), 'Türplatte unter dem Spot')
})
test('Zeichnung trifft den Spot: Schublade eines Aktenschranks bei spots.archiv, nicht auf Arne', () => {
const spot = layout.spots.archiv
const hit = hitsAt('keller', spot)
// oberste Schubladenfront (10 × 5) des ersten Aktenschranks
assert.ok(hit.some((r) => r.fill === PAL.metal && r.w === 10 * 3 && r.h === 5 * 3), 'Schubladenfront unter dem Spot')
assert.ok(hit.some((r) => r.fill === PAL.metal2 && r.w === 12 * 3), 'Schrankkorpus unter dem Spot')
// Arnes Platz (Körper 12 breit, 14 hoch, Hut ab y - 2) liegt nicht auf dem Spot
const arne = layout.homes.arne
assert.ok(!(spot.x >= arne.x && spot.x < arne.x + 12 && spot.y >= arne.y - 2 && spot.y < arne.y + 14))
})
test('grauer Raum: Teamfarbton bleibt, das Schild ersetzt den Titel (kein Platz für beides)', () => {
const floor = layout.floors.find((f) => f.id === 'anfragen')
const room = floor.rooms.find((r) => r.id === 'kleinanzeigen')
assert.equal(room.w, 46)
const run = (look) => {
const m = makeCtx()
R.drawRooms(m.ctx, { ...layout, floors: [{ ...floor, rooms: [room] }] }, 1, look)
return m
}
const normal = run(looks())
const grey = run(looks({ greyRooms: new Set(['kleinanzeigen']) }))
const tintRects = (m) => m.rects.filter((r) => r.fill === TEAM[room.team] && r.w === room.w * 3 && r.h === floor.h * 3)
assert.equal(tintRects(normal).length, 1)
assert.equal(tintRects(grey).length, 1, 'Teamfarbton auch im grauen Raum')
assert.ok(grey.alphas.includes(0.1) && grey.alphas.includes(0.45), 'Teamfarbton 0,10, darüber die Grautönung 0,45')
assert.ok(normal.texts.some((x) => x.text === room.title))
assert.ok(!grey.texts.some((x) => x.text === room.title), 'im grauen Raum steht das Schild an der Stelle des Titels')
assert.equal(grey.texts.filter((x) => x.text === SIGN).length, 1)
assert.equal(normal.texts.filter((x) => x.text === SIGN).length, 0)
})
test('Schilder: drawRooms mit signs: false lässt sie aus (Grautönung bleibt), drawSigns zeichnet genau diese Schilder an dieselbe Stelle', () => {
const look = looks({ greyRooms: new Set(['kleinanzeigen', 'portale']) })
const withSigns = draw(look)
const m = makeCtx()
R.drawRooms(m.ctx, layout, 1, look, { signs: false })
assert.equal(m.texts.filter((x) => x.text === SIGN).length, 0)
assert.equal(m.rects.length < withSigns.rects.length, true)
assert.ok(m.alphas.includes(0.45), 'die Grautönung gehört weiter zum Raum')
assert.deepEqual(m.texts.filter((x) => x.text !== SIGN).map((x) => x.text), withSigns.texts.filter((x) => x.text !== SIGN).map((x) => x.text))
const s = makeCtx()
R.drawSigns(s.ctx, layout, look)
assert.deepEqual(s.texts.map((x) => x.text), [SIGN, SIGN])
assert.deepEqual(s.texts.map((x) => [x.x, x.y]), withSigns.texts.filter((x) => x.text === SIGN).map((x) => [x.x, x.y]))
const none = makeCtx()
R.drawSigns(none.ctx, layout, R.createLook())
assert.equal(none.rects.length + none.texts.length, 0)
const noWorker = makeCtx()
R.drawSigns(noWorker.ctx, layout, looks({ greyRooms: new Set(['pult', 'tresor']) })) // Räume ohne Worker werden nie grau
assert.equal(noWorker.texts.length, 0)
})
test('Raumtitel: über den Möbeln gezeichnet (ein Monitor deckt sie nicht zu) und mit hellem Rand', () => {
const floor = layout.floors.find((f) => f.id === 'gespraeche')
const room = floor.rooms.find((r) => r.id === 'mail')
const m = makeCtx()
R.drawRooms(m.ctx, { ...layout, floors: [{ ...floor, rooms: [room] }] }, 1, R.createLook())
assert.equal(m.strokes.length, 1)
assert.equal(m.strokes[0].text, room.title)
assert.ok(m.strokes[0].style.startsWith('rgba(255, 255, 255'))
assert.equal(m.strokes[0].rects, m.rects.length, 'nach dem Titel kommt keine Fläche mehr: Möbel, Tisch und Monitor liegen darunter')
assert.ok(m.rects.length > 20)
assert.equal(m.texts.filter((x) => x.text === room.title).length, 1)
const grey = makeCtx()
R.drawRooms(grey.ctx, { ...layout, floors: [{ ...floor, rooms: [{ ...room, id: 'kleinanzeigen' }] }] }, 1, looks({ greyRooms: new Set(['kleinanzeigen']) }))
assert.equal(grey.strokes.length, 0, 'im grauen Raum steht kein Titel')
})
-620
View File
@@ -1,620 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createEngineSandbox } from './helpers/engineVm.mjs'
import { makeState, ev, IDS } from './helpers/workersFixtures.mjs'
// Szene: mountTower (Größe, 30-fps-Schleife, Eingabe, Kamera, Lebenszyklus) in einer vm-Sandbox ohne window/document
const sel = []
const open = async (o = {}, extra = {}) => {
const sb = await createEngineSandbox(o)
const h = sb.mountTower(sb.canvas, sb.opts({ onSelect: (id) => sel.push(id), ...extra }))
return { sb, h }
}
test('Handle hat die Methoden aus Übersicht §7 und E4; Mount zeichnet sofort', async () => {
const { sb, h } = await open()
for (const m of ['destroy', 'setState', 'pushEvents', 'select', 'setPaused', 'flush', 'resize', 'zoomBy', 'resetView', 'locate', 'stats', 'debug']) {
assert.equal(typeof h[m], 'function', m)
}
assert.ok(sb.counts.fillRect > 100)
h.destroy()
})
test('Größe: Breite·dpr (max. 2400), Höhe 4/5 davon', async () => {
const a = await open({ cssWidth: 960, dpr: 1 })
assert.deepEqual([a.sb.canvas.width, a.sb.canvas.height], [960, 768])
a.h.destroy()
const b = await open({ cssWidth: 1500, dpr: 2 })
assert.deepEqual([b.sb.canvas.width, b.sb.canvas.height], [2400, 1920])
b.h.destroy()
})
test('Takt: höchstens 30 Bilder je Sekunde', async () => {
const { sb, h } = await open()
h.setState(makeState())
const f0 = h.stats().frames
sb.tick(1)
const f = h.stats().frames - f0
assert.ok(f >= 28 && f <= 32, String(f))
h.destroy()
})
test('Lebenszyklus: destroy doppelt, nichts danach, Listener weg; StrictMode mount → destroy → mount', async () => {
const sb = await createEngineSandbox()
const a = sb.mountTower(sb.canvas, sb.opts({}))
a.destroy()
a.destroy()
const n = sb.counts.fillRect
sb.tick(1)
assert.equal(sb.counts.fillRect, n)
assert.equal(Object.values(sb.listeners).flat().length, 0)
const b = sb.mountTower(sb.canvas, sb.opts({}))
sb.tick(0.5)
assert.ok(sb.counts.fillRect > n)
a.setState(makeState())
a.pushEvents([ev('fund', 'kim')])
b.destroy()
assert.equal(Object.values(sb.listeners).flat().length, 0)
})
test('fontsReady: Ablehnung oder Zerstörung davor lässt nichts mehr zeichnen', async () => {
let resolve
const p = new Promise((r) => { resolve = r })
const { sb, h } = await open({}, { fontsReady: p })
h.destroy()
const n = sb.counts.fillRect
resolve()
await new Promise((r) => setImmediate(r))
assert.equal(sb.counts.fillRect, n)
// das abgelehnte Versprechen erst nach dem Sandbox-Aufbau erzeugen, sonst „unhandled rejection“
const sb2 = await createEngineSandbox()
sb2.mountTower(sb2.canvas, sb2.opts({ fontsReady: Promise.reject(new Error('x')) })).destroy()
})
test('reducedMotion: keine Schleife, jede Änderung zeichnet ein Standbild und beruhigt die Regie', async () => {
const { sb, h } = await open({}, { reducedMotion: true })
assert.equal(sb.rafPending(), 0)
const n = sb.counts.fillRect
h.setState(makeState())
h.pushEvents(Array.from({ length: 30 }, (_, i) => ev('uebergabe', IDS[i % 19], { to: IDS[(i + 4) % 19] })))
assert.ok(sb.counts.fillRect > n)
assert.equal(h.debug().pending, 0)
assert.equal(sb.rafPending(), 0)
h.destroy()
})
test('setPaused hält die Schleife an und setzt sie fort', async () => {
const { sb, h } = await open()
h.setPaused(true)
sb.tick(0.2)
const n = sb.counts.fillRect
sb.tick(1)
assert.equal(sb.counts.fillRect, n)
h.setPaused(false)
sb.tick(0.5)
assert.ok(sb.counts.fillRect > n)
h.destroy()
})
test('Klick trifft über die Umkehr-Transformation, auch gezoomt und verschoben', async () => {
const { sb, h } = await open()
h.setState(makeState())
sel.length = 0
for (const id of ['otto', 'ben', 'arne']) {
const p = h.locate(id)
sb.click(p.x, p.y)
assert.equal(sel.at(-1), id)
}
h.zoomBy(2.5)
h.select('ben')
const p = h.locate('ben')
assert.ok(p)
sb.click(p.x, p.y)
assert.equal(sel.at(-1), 'ben')
assert.equal(h.stats().zoom, 2.5)
h.resetView()
assert.equal(h.stats().zoom, 1)
sel.length = 0
h.select('kim')
assert.equal(sel.length, 0)
h.destroy()
})
test('Ziehen verschiebt bei Zoom > 1 und unterdrückt den Klick', async () => {
const { sb, h } = await open()
h.setState(makeState())
h.zoomBy(2)
h.select('bea')
const before = h.locate('bea')
sel.length = 0
sb.pointer('pointerdown', 400, 300)
sb.pointer('pointermove', 460, 300)
sb.pointer('pointerup', 460, 300)
sb.click(460, 300)
assert.equal(sel.length, 0)
const after = h.locate('bea')
// 60 CSS-Pixel nach rechts gezogen
assert.ok(Math.abs(after.x - (before.x + 60)) < 2)
assert.ok(Math.abs(after.y - before.y) < 1e-6)
h.destroy()
})
// ---------- Größe und Startansicht ----------
test('resize() folgt der Bühne; unter 10 CSS-Pixeln wird nicht gemessen', async () => {
const { sb, h } = await open({ cssWidth: 960 })
sb.setCssWidth(500)
h.resize()
assert.deepEqual([sb.canvas.width, sb.canvas.height], [500, 400])
sb.setCssWidth(5)
h.resize()
assert.deepEqual([sb.canvas.width, sb.canvas.height], [500, 400])
h.destroy()
})
test('unsichtbare Bühne (Breite unter 10): nichts wird gezeichnet, bis resize() sie misst', async () => {
const { sb, h } = await open({ cssWidth: 5 })
h.setState(makeState())
sb.tick(1)
assert.equal(sb.counts.fillRect, 0)
assert.equal(h.stats().frames, 0)
sb.setCssWidth(960)
h.resize()
assert.deepEqual([sb.canvas.width, sb.canvas.height], [960, 768])
assert.ok(sb.counts.fillRect > 100)
h.destroy()
})
test('Startansicht: breite Bühne Zoom 1, schmale fitZoom; die Touch-Aktion folgt dem Zoom', async () => {
const wide = await open({ cssWidth: 960 })
assert.equal(wide.h.stats().zoom, 1)
assert.equal(wide.sb.canvas.style.touchAction, 'pan-y')
wide.h.destroy()
const narrow = await open({ cssWidth: 375 })
narrow.h.setState(makeState())
assert.ok(Math.abs(narrow.h.stats().zoom - 560 / 375) < 1e-9)
assert.equal(narrow.sb.canvas.style.touchAction, 'none')
// alle Figuren der oberen Etagen liegen in der Ansicht (die untere Hälfte des Turms ist bei Zoom > 1 abgeschnitten)
const upper = narrow.h.debug().agents.filter((a) => a.y < 120)
assert.ok(upper.length >= 10)
for (const a of upper) assert.ok(narrow.h.locate(a.id), a.id)
narrow.h.resetView()
assert.equal(narrow.h.stats().zoom, 1)
assert.equal(narrow.sb.canvas.style.touchAction, 'pan-y')
narrow.h.destroy()
})
test('Startansicht auf schmaler Bühne: alle Räume (x 42..228) liegen in der Breite', async () => {
const { sb, h } = await open({ cssWidth: 375, record: true })
const k = sb.canvas.width / (320 / h.stats().zoom)
const backWalls = sb.rects.filter((r) => Math.abs(r.w - 186 * k) <= 1)
assert.ok(backWalls.length >= 10, 'Rückwand und Bodenstreifen jeder Etage sind 186 Einheiten breit')
for (const r of backWalls) assert.ok(r.x >= 0 && r.x + r.w <= sb.canvas.width, JSON.stringify(r))
h.destroy()
})
test('resize() zieht den Startausschnitt nach, bis der Nutzer die Kamera bewegt hat', async () => {
const { sb, h } = await open({ cssWidth: 375 })
assert.ok(h.stats().zoom > 1.4)
sb.setCssWidth(960)
h.resize()
assert.equal(h.stats().zoom, 1)
sb.setCssWidth(375)
h.resize()
h.zoomBy(2)
const z = h.stats().zoom
sb.setCssWidth(960)
h.resize()
assert.equal(h.stats().zoom, z)
h.destroy()
})
// ---------- Zeichnen ----------
test('Zeichenreihenfolge: Weiß, Außenwelt, Turm, Räume, Aufzug hinten, Figuren, Schilder, Aufzug vorn, Pausenschleier', async () => {
const { sb, h } = await open({ record: true })
const state = makeState({ paused: true, connected: { kim: false } })
h.setState(state)
sb.rects.length = 0
sb.texts.length = 0
h.setState(state) // genau ein Bild
const white = sb.rects[0]
assert.deepEqual([white.x, white.y, white.w, white.h, white.fill], [0, 0, 960, 768, '#ffffff'])
const rectN = (pred, what) => {
const r = sb.rects.find(pred)
assert.ok(r, what)
return r.n
}
const textN = (text) => {
const t = sb.texts.find((x) => x.text === text)
assert.ok(t, text)
return t.n
}
const outside = rectN((r) => r.fill === '#f7f4f0', 'Erde der Außenwelt')
const tower = rectN((r) => r.fill === '#e4e8ef', 'Aufzugschacht')
const rooms = textN('Netz-Radar') // Raumtitel
const liftBack = rectN((r) => r.fill === '#f1f3f8', 'Rückwand der Kabine')
const figure = rectN((r) => r.alpha === 0.55, 'graue Figur')
const sign = textN('noch nicht angeschlossen') // das Schild liegt über dem Hut der schlafenden Figur (sonst deckt der Hut den Text zu)
const liftFront = rectN((r) => r.fill === '#ffe9a8', 'Lampe der Kabine')
const veil = textN('Pausiert')
assert.deepEqual([outside, tower, rooms, liftBack, figure, sign, liftFront, veil].toSorted((a, b) => a - b), [outside, tower, rooms, liftBack, figure, sign, liftFront, veil])
assert.equal(sb.texts.filter((t) => t.text === 'noch nicht angeschlossen').length, 1, 'genau ein Schild, nicht zweimal')
assert.equal(sb.texts.at(-1).text, 'Pausiert')
h.destroy()
})
test('Effekte liegen über dem Aufzug vorn und unter dem Pausenschleier', async () => {
const { sb, h } = await open({ record: true })
h.setState(makeState())
h.pushEvents([ev('vorschau_link', 'max')])
sb.tick(2) // erst Sprechblase, dann fliegt der Brief (Lebensdauer 3,4 s)
assert.ok(h.debug().fx > 0)
sb.rects.length = 0
sb.texts.length = 0
h.setState(makeState({ paused: true })) // genau ein Bild, der Brief ist noch unterwegs
const lamp = sb.rects.find((r) => r.fill === '#ffe9a8')
const mail = sb.rects.filter((r) => r.fill === '#7a5cd6' && r.w === 21 && r.h === 15).at(-1)
const veil = sb.texts.find((t) => t.text === 'Pausiert')
assert.ok(lamp && mail && veil)
assert.ok(lamp.n < mail.n && mail.n < veil.n, [lamp.n, mail.n, veil.n].join(' < '))
h.destroy()
})
test('Pausenschleier nur bei pausiertem Dienst', async () => {
const { sb, h } = await open()
h.setState(makeState())
assert.equal(sb.seenTexts.has('Pausiert'), false)
h.setState(makeState({ paused: true }))
assert.equal(sb.seenTexts.has('Pausiert'), true)
h.destroy()
})
test('stats().avgDrawMs misst die Zeit eines Bildes mit der Uhr der Szene', async () => {
const sb = await createEngineSandbox()
let t = 0
const h = sb.mountTower(sb.canvas, sb.opts({ reducedMotion: true, now: () => { t += 2; return t } }))
h.setState(makeState())
const s = h.stats()
assert.equal(s.frames, 2) // Mount und setState
assert.equal(s.avgDrawMs, 2)
h.destroy()
})
test('Schleife treibt die Regie: eine Übergabe über sieben Etagen läuft in der Szene zu Ende', async () => {
const { sb, h } = await open()
h.setState(makeState())
const arne = () => h.debug().agents.find((a) => a.id === 'arne')
const home = { x: arne().x, y: arne().y, floor: arne().floor }
h.pushEvents([ev('uebergabe', 'arne', { to: 'bea' })])
assert.equal(h.debug().pending, 1)
let away = false
for (let s = 0; s < 40 && h.debug().pending > 0; s++) {
sb.tick(1)
if (Math.abs(arne().x - home.x) > 10 || arne().floor !== home.floor) away = true
}
assert.ok(away)
assert.equal(h.debug().pending, 0)
sb.tick(2)
assert.equal(h.debug().lift, 'idle')
assert.deepEqual({ x: arne().x, y: arne().y, floor: arne().floor }, home)
h.destroy()
})
test('dt wird nach einem Stillstand auf DT_MAX gekappt', async () => {
const { sb, h } = await open()
h.setState(makeState())
h.pushEvents([ev('uebergabe', 'arne', { to: 'bea' })])
sb.tick(1) // 0,4 s Aufnehmen, dann unterwegs zum Aufzug
const at = () => h.debug().agents.find((a) => a.id === 'arne')
const before = { x: at().x, y: at().y }
sb.jump(60) // die Uhr springt, ohne dass ein Bild kommt
sb.tick(2 / 60) // ein Bild mit Δt = 60 s
const moved = Math.hypot(at().x - before.x, at().y - before.y)
assert.ok(moved > 0 && moved <= 46 * sb.engine.DT_MAX + 1e-6, String(moved))
h.destroy()
})
// ---------- Eingabe ----------
test('Klick ins Leere wählt ab (onSelect(null))', async () => {
const { sb, h } = await open()
h.setState(makeState())
sel.length = 0
const p = h.locate('otto')
sb.click(p.x, p.y)
sb.click(5, 5)
assert.deepEqual(sel, ['otto', null])
h.destroy()
})
test('coarsePointer weitet die Trefferfläche von 4 auf 10 CSS-Pixel', async () => {
for (const [coarsePointer, expected] of [[false, null], [true, 'arne']]) {
const { sb, h } = await open({}, { coarsePointer })
h.setState(makeState())
sel.length = 0
const p = h.locate('arne')
sb.click(p.x + 25, p.y) // die Figur ist 12 Einheiten = 36 CSS-Pixel breit, 25 > 18 + 4 und 25 < 18 + 10
assert.equal(sel.at(-1), expected, String(coarsePointer))
h.destroy()
}
})
test('zoomBy zoomt um die Mitte der Ansicht', async () => {
const { h } = await open()
h.setState(makeState())
const before = h.locate('bea')
h.zoomBy(1.4)
const after = h.locate('bea')
assert.ok(Math.abs(after.x - (480 + (before.x - 480) * 1.4)) < 1, JSON.stringify(after))
assert.ok(Math.abs(after.y - (384 + (before.y - 384) * 1.4)) < 1, JSON.stringify(after))
h.destroy()
})
test('Strg+Rad zoomt um den Zeiger, ohne Strg bleibt das Rad der Seite; Schritt begrenzt, passive: false', async () => {
const { sb, h } = await open()
h.setState(makeState())
const free = sb.wheel(480, 384, -100)
assert.equal(free.defaultPrevented, false)
assert.equal(h.stats().zoom, 1)
const p = h.locate('arne')
const e = sb.wheel(p.x, p.y, -100, { ctrlKey: true })
assert.equal(e.defaultPrevented, true)
assert.ok(Math.abs(h.stats().zoom - sb.engine.ZOOM_STEP) < 1e-9)
const q = h.locate('arne')
assert.ok(Math.abs(q.x - p.x) < 1 && Math.abs(q.y - p.y) < 1, 'der Punkt unter dem Zeiger bleibt stehen')
sb.wheel(p.x, p.y, 100, { ctrlKey: true })
assert.ok(Math.abs(h.stats().zoom - 1) < 1e-9)
sb.wheel(480, 384, -1e6, { ctrlKey: true })
assert.ok(Math.abs(h.stats().zoom - sb.engine.ZOOM_STEP ** 2) < 1e-9)
const wheel = sb.listeners.wheel[0]
assert.equal(sb.listenerOptions.get(wheel).passive, false)
h.destroy()
})
test('Ziehen bei Zoom 1 verschiebt nichts, unterdrückt aber den Klick; kleine Bewegung und die rechte Maustaste', async () => {
const { sb, h } = await open()
h.setState(makeState())
const before = h.locate('bea')
sel.length = 0
sb.pointer('pointerdown', 400, 300)
sb.pointer('pointermove', 460, 300)
sb.pointer('pointerup', 460, 300)
sb.click(before.x, before.y)
assert.equal(sel.length, 0)
assert.deepEqual(h.locate('bea'), before)
// der folgende Klick gilt wieder
sb.click(before.x, before.y)
assert.deepEqual(sel, ['bea'])
// bis DRAG_PX Wanderung bleibt ein Klick
sb.pointer('pointerdown', before.x, before.y)
sb.pointer('pointermove', before.x + sb.engine.DRAG_PX, before.y)
sb.pointer('pointerup', before.x + sb.engine.DRAG_PX, before.y)
sb.click(before.x, before.y)
assert.deepEqual(sel, ['bea', 'bea'])
// die rechte Maustaste startet kein Ziehen
sb.pointer('pointerdown', 400, 300, { button: 2 })
sb.pointer('pointermove', 460, 300)
sb.click(before.x, before.y)
assert.deepEqual(sel, ['bea', 'bea', 'bea'])
h.destroy()
})
test('Zeiger-Erfassung beim Ziehen; ein Fehler dabei stört das Ziehen nicht', async () => {
const { sb, h } = await open()
h.setState(makeState())
h.zoomBy(2)
h.select('bea')
const calls = []
sb.canvas.setPointerCapture = (id) => calls.push(['set', id])
sb.canvas.releasePointerCapture = (id) => calls.push(['release', id])
sb.pointer('pointerdown', 400, 300)
sb.pointer('pointercancel', 400, 300)
assert.deepEqual(calls, [['set', 1], ['release', 1]])
sb.canvas.setPointerCapture = () => { throw new Error('InvalidPointerId') }
sb.canvas.releasePointerCapture = () => { throw new Error('InvalidPointerId') }
const before = h.locate('bea')
sb.pointer('pointerdown', 400, 300)
sb.pointer('pointermove', 450, 300)
sb.pointer('pointerup', 450, 300)
assert.ok(Math.abs(h.locate('bea').x - (before.x + 50)) < 2)
h.destroy()
})
test('select() von außen holt die Figur bei Zoom > 1 in die Mitte, der Klick selbst verschiebt die Ansicht nicht', async () => {
const { sb, h } = await open()
h.setState(makeState())
h.zoomBy(3)
h.select('rita')
const mid = h.locate('rita')
assert.ok(Math.abs(mid.x - 480) < 1 && Math.abs(mid.y - 384) < 1, JSON.stringify(mid))
sb.pointer('pointerdown', 400, 300)
sb.pointer('pointermove', 450, 300)
sb.pointer('pointerup', 450, 300)
sb.click(450, 300) // gilt als Ende des Ziehens
const dragged = h.locate('rita')
assert.ok(Math.abs(dragged.x - (mid.x + 50)) < 2)
sel.length = 0
sb.click(dragged.x, dragged.y)
assert.deepEqual(sel, ['rita'])
h.select('rita') // so antwortet die Seite auf onSelect
assert.deepEqual(h.locate('rita'), dragged)
h.select('rita') // jetzt von außen (Liste): in die Mitte
const again = h.locate('rita')
assert.ok(Math.abs(again.x - 480) < 1 && Math.abs(again.y - 384) < 1)
h.destroy()
})
test('locate: null außerhalb der Ansicht und für Unbekanntes', async () => {
const { h } = await open()
h.setState(makeState())
h.zoomBy(3)
h.select('otto')
assert.ok(h.locate('otto'))
assert.equal(h.locate('arne'), null)
for (const id of ['niemand', '__proto__', 'constructor', '', 42, null, undefined]) assert.equal(h.locate(id), null, String(id))
h.destroy()
})
// ---------- Lebenszyklus und Sonderfälle ----------
test('nach destroy ist jeder Aufruf wirkungslos', async () => {
const { sb, h } = await open()
h.setState(makeState())
h.destroy()
const n = sb.counts.fillRect
const zoom = h.stats().zoom
h.setState(makeState({ paused: true }))
h.pushEvents([ev('fund', 'kim')])
h.select('kim')
h.zoomBy(2)
h.resetView()
h.resize()
h.setPaused(true)
h.setPaused(false)
sb.click(100, 100)
assert.equal(sb.counts.fillRect, n)
assert.equal(h.stats().zoom, zoom)
assert.equal(h.locate('kim'), null)
assert.equal(sb.rafPending(), 0)
})
test('Standbild: das Aufblitzen des Tresors nach einem Deal bleibt nicht stehen', async () => {
const { sb, h } = await open({ record: true }, { reducedMotion: true })
h.setState(makeState())
sb.rects.length = 0
h.pushEvents([ev('deal', 'system'), ev('deal', 'system')])
assert.ok(sb.rects.length > 100)
assert.equal(sb.rects.filter((r) => r.fill === '#e8b730' && r.alpha > 0 && r.alpha < 0.5).length, 0)
h.destroy()
})
test('setPaused(true) bestellt keinen Bildrückruf mehr, setPaused(false) genau einen', async () => {
const { sb, h } = await open()
assert.equal(sb.rafPending(), 1)
h.setPaused(true)
assert.equal(sb.rafPending(), 0)
h.setPaused(true)
h.setPaused(false)
h.setPaused(false)
assert.equal(sb.rafPending(), 1)
h.destroy()
assert.equal(sb.rafPending(), 0)
})
test('ohne wirksames caf läuft nach Pause und Fortsetzen nie eine zweite Schleife', async () => {
const { sb, h } = await open({}, { caf: () => {} })
h.setPaused(true)
h.setPaused(false) // der alte Rückruf steht noch in der Warteschlange
assert.equal(sb.rafPending(), 2)
sb.tick(0.1)
assert.equal(sb.rafPending(), 1)
const f0 = h.stats().frames
sb.tick(1)
const f = h.stats().frames - f0
assert.ok(f >= 28 && f <= 32, String(f))
h.destroy()
})
test('setPaused im Standbild startet keine Schleife', async () => {
const { sb, h } = await open({}, { reducedMotion: true })
h.setPaused(true)
h.setPaused(false)
assert.equal(sb.rafPending(), 0)
h.destroy()
})
test('ohne raf (Option und global) bleibt es beim Standbild; die Standardwerte werden erst beim Aufruf gelesen', async () => {
const sb = await createEngineSandbox()
const h = sb.mountTower(sb.canvas, { onSelect() {} })
assert.ok(sb.counts.fillRect > 100)
assert.equal(sb.rafPending(), 0)
h.setState(makeState())
h.destroy()
})
test('fontsReady erfüllt: danach wird einmal neu gezeichnet', async () => {
const sb = await createEngineSandbox()
const h = sb.mountTower(sb.canvas, sb.opts({ fontsReady: Promise.resolve() }))
const n = sb.counts.fillRect
await new Promise((r) => setImmediate(r))
assert.ok(sb.counts.fillRect > n)
h.destroy()
})
test('Canvas ohne 2D-Kontext: ein Handle, das nichts tut', async () => {
const sb = await createEngineSandbox()
sb.canvas.getContext = () => null
const h = sb.mountTower(sb.canvas, sb.opts({}))
h.setState(makeState())
h.pushEvents([ev('fund', 'kim')])
h.select('kim')
h.zoomBy(2)
h.resetView()
h.resize()
h.setPaused(true)
assert.equal(h.locate('kim'), null)
assert.equal(h.stats().frames, 0)
assert.equal(h.debug().pending, 0)
h.destroy()
assert.equal(sb.counts.fillRect, 0)
assert.equal(sb.rafPending(), 0)
assert.equal(Object.values(sb.listeners).flat().length, 0)
})
test('debug() und stats(): Form', async () => {
const { sb, h } = await open()
h.setState(makeState({ connected: { kim: false } }))
sb.tick(1)
const d = h.debug()
assert.equal(d.agents.length, 19)
assert.deepEqual(Object.keys(d.agents[0]).sort(), ['floor', 'grey', 'id', 'pose', 'x', 'y'])
assert.equal(d.agents.find((a) => a.id === 'kim').grey, true)
assert.deepEqual([typeof d.pending, typeof d.lift, typeof d.fx], ['number', 'string', 'number'])
const s = h.stats()
assert.deepEqual(Object.keys(s).sort(), ['avgDrawMs', 'frames', 'zoom'])
assert.ok(s.frames > 25)
h.destroy()
})
test('flush(): Rückkehr in den Tab beruhigt die Regie (alles heim, Aufzug und Effekte leer) und zeichnet; nach destroy wirkungslos', async () => {
const { sb, h } = await open()
h.setState(makeState())
const arne = () => h.debug().agents.find((a) => a.id === 'arne')
const home = { x: arne().x, y: arne().y, floor: arne().floor }
h.pushEvents([ev('uebergabe', 'arne', { to: 'bea' })])
sb.tick(3)
assert.ok(h.debug().pending > 0)
h.setPaused(true)
const n = sb.counts.fillRect
h.setPaused(false)
h.flush()
assert.ok(sb.counts.fillRect > n)
assert.equal(h.debug().pending, 0)
assert.equal(h.debug().lift, 'idle')
assert.equal(h.debug().fx, 0)
assert.deepEqual({ x: arne().x, y: arne().y, floor: arne().floor }, home)
h.pushEvents([ev('uebergabe', 'arne', { to: 'bea' })])
assert.equal(h.debug().pending, 1)
h.destroy()
const m = sb.counts.fillRect
h.flush()
assert.equal(sb.counts.fillRect, m)
assert.equal(h.debug().pending, 1)
})
test('flush() im Standbild zeichnet ein Standbild ohne Bildrückruf; ohne 2D-Kontext tut es nichts', async () => {
const { sb, h } = await open({}, { reducedMotion: true })
h.setState(makeState())
const n = sb.counts.fillRect
h.flush()
assert.ok(sb.counts.fillRect > n)
assert.equal(sb.rafPending(), 0)
h.destroy()
const bare = await createEngineSandbox()
bare.canvas.getContext = () => null
const g = bare.mountTower(bare.canvas, bare.opts({}))
g.flush()
assert.equal(bare.counts.fillRect, 0)
})
-118
View File
@@ -1,118 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createEngineSandbox } from './helpers/engineVm.mjs'
import { makeState, ev, IDS } from './helpers/workersFixtures.mjs'
import { loadModule } from './load.mjs'
// Rauchtest der Engine ohne Browser (Muster: entwuerfe/smoke.js): 19 Figuren sind anklickbar, 150 simulierte Sekunden
// mit allen 26 Ereignisarten und einem Stoß von 120 Übergaben laufen ohne Wurf und beruhigen sich danach wieder.
const { EVENT_KINDS } = await loadModule('src/lib/workersEvents.js')
// Eine Art alle 5 s ab Sekunde 3, in dieser Reihenfolge: [Art, Arbeiter, Zusatzfelder]
const PLAN = [
['gelesen', 'kim'],
['fund', 'rita'],
['doppelt', 'arne'],
['uebergabe', 'rita', { to: 'bea' }],
['bewertet', 'bea'],
['top3', 'bea'],
['botlead', 'arne'],
['entwurf', 'max'],
['pult', 'clara'],
['gesendet', 'max'],
['eingang', 'nora'],
['sortiert', 'nora'],
['bau_start', 'bob'],
['bau_fertig', 'bob'],
['pruefung_zurueck', 'paul', { to: 'bob', meta: { round: 1 } }],
['pruefung_ok', 'paul'],
['vorschau_link', 'max'],
['angebot', 'theo'],
['deal', 'system'],
['gesperrt', 'system'],
['budget_warnung', 'otto'],
['budget_stopp', 'otto'],
['pause', 'system'],
['fehler', 'ben'],
['auftrag', 'otto', { to: 'kim' }],
['leerlauf', 'pia'],
]
const FIRST_SEC = 3
const EVERY_SEC = 5
const BURST_SEC = 100
const BURST_SIZE = 120
// Die Ereignisse für Sekunde `sec`: höchstens eine Art aus dem Plan, ab Sekunde 100 zusätzlich der Stoß (einmalig)
function scripted(sec) {
const out = []
const slot = (sec - FIRST_SEC) / EVERY_SEC
if (Number.isInteger(slot) && slot >= 0 && slot < PLAN.length) {
const [kind, who, extra] = PLAN[slot]
out.push(ev(kind, who, extra))
}
if (sec === BURST_SEC) {
for (let i = 0; i < BURST_SIZE; i++) out.push(ev('uebergabe', IDS[i % IDS.length], { to: IDS[(i + 4) % IDS.length] }))
}
return out
}
const OFF = { kim: false, pia: false, finn: false }
test('alle 19 Worker sind anklickbar (Auswahl liefert die richtige workerId)', async () => {
const sb = await createEngineSandbox()
const picked = []
const h = sb.mountTower(sb.canvas, sb.opts({ onSelect: (id) => picked.push(id) }))
h.setState(makeState())
for (const id of IDS) {
const p = h.locate(id)
assert.ok(p, id)
sb.click(p.x, p.y)
assert.equal(picked.at(-1), id)
}
assert.deepEqual([...new Set(picked)].sort(), [...IDS].sort())
h.destroy()
})
test('150 s mit allen 26 Ereignisarten: kein Wurf, alle endlich und im Bild, danach ruhig', async () => {
const sb = await createEngineSandbox()
const h = sb.mountTower(sb.canvas, sb.opts({ onSelect() {} }))
h.setState(makeState({ connected: OFF }))
const fed = []
for (let s = 0; s < 150; s++) {
const evs = scripted(s)
if (evs.length) {
fed.push(...evs)
h.pushEvents(evs)
}
if (s % 15 === 0) {
h.setState(makeState({ connected: OFF, kpis: { pultOpen: s % 7, dealsMonth: 3 + Math.floor(s / 40), botLeads: 112 + s } }))
}
sb.tick(1)
// alle zwei Sekunden ein Raster von Klicks über die ganze Leinwand
if (s % 2 === 0) {
for (let x = 10; x < 960; x += 40) {
for (let y = 10; y < 768; y += 40) sb.click(x, y)
}
}
}
// der Plan deckt die geschlossene Liste vollständig ab, der Stoß kam genau einmal
assert.equal(PLAN.length, 26)
assert.deepEqual([...new Set(fed.map((e) => e.kind))].sort(), [...EVENT_KINDS].sort())
assert.equal(fed.length, PLAN.length + BURST_SIZE)
assert.equal(fed.filter((e) => e.kind === 'uebergabe').length, BURST_SIZE + 1)
// danach 90 ruhige Sekunden: Warteschlangen, Aufzug und Effekte laufen leer
for (let s = 0; s < 90; s++) sb.tick(1)
const dbg = h.debug()
assert.equal(dbg.pending, 0)
assert.equal(dbg.lift, 'idle')
assert.equal(dbg.fx, 0)
for (const a of dbg.agents) {
assert.ok(Number.isFinite(a.x) && Number.isFinite(a.y) && a.x > -30 && a.x < 350 && a.floor >= 0 && a.floor <= 9, a.id)
}
assert.ok(sb.counts.fillRect > 100000)
assert.ok(sb.counts.fillText > 1000)
assert.ok(sb.seenTexts.has('noch nicht angeschlossen'))
h.destroy()
assert.equal(Object.values(sb.listeners).flat().length, 0)
})
-189
View File
@@ -1,189 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { loadModule } from './load.mjs'
import { makeCtx } from './helpers/fakeCtx.mjs'
// Engine-Grundlagen: Raster, Figuren mit Posen, Text, Sprechblasen
const S = await loadModule('src/components/workers/engine/sprites.js')
test('px: gerundete Kanten ohne Naht, Standardraster 3, Versatz, Mindestgröße', () => {
let { ctx, rects } = makeCtx(); S.setGrid(ctx, { k: 3.4, ox: 0, oy: 0 })
S.px(ctx, 0, 0, 1, 1, '#000'); S.px(ctx, 1, 0, 1, 1, '#111'); S.px(ctx, 2, 0, 1, 1, '#222')
assert.equal(rects[0].x + rects[0].w, rects[1].x); assert.equal(rects[1].x + rects[1].w, rects[2].x)
;({ ctx, rects } = makeCtx()); S.px(ctx, 1, 2, 3, 4, '#fff'); assert.deepEqual([rects[0].x, rects[0].y, rects[0].w, rects[0].h], [3, 6, 9, 12])
;({ ctx, rects } = makeCtx()); S.setGrid(ctx, { k: 2, ox: -10, oy: 0 }); S.px(ctx, 5, 0, 0.1, 1, '#fff'); assert.equal(rects[0].x, 0); assert.ok(rects[0].w >= 1)
})
test('label: Schriftgröße wächst mit dem Raster', () => {
const { ctx, texts } = makeCtx(); S.setGrid(ctx, { k: 6 }); S.label(ctx, 'Hallo', 1, 1, { size: 11 }); assert.match(texts[0].font, /22px/)
})
test('drawWorker: Trefferfläche', () => {
const { ctx } = makeCtx()
assert.deepEqual(S.drawWorker(ctx, 10, 20, { hat: 'haiku' }), { x: 10, y: 18, w: 12, h: 16 })
assert.equal(S.drawWorker(ctx, 10, 20, { hat: 'haiku', carrying: true }).w, 14)
assert.deepEqual(S.drawWorker(ctx, 10, 20, { hat: 'overseer', scale: 2 }), { x: 10, y: 16, w: 24, h: 32 })
})
test('drawWorker: Hutfarbe zeigt das Modell, Augen je Pose, Grau mit Alpha', () => {
for (const hat of ['opus', 'sonnet', 'haiku', 'overseer']) { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat }); assert.ok(rects.some((r) => r.fill === S.HAT[hat][0]), hat) }
const eyes = (pose) => { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat: 'haiku', pose }); return rects.filter((r) => r.fill === S.PAL.e).length }
assert.equal(eyes('stand'), 4); assert.equal(eyes('sleep'), 2)
for (const pose of ['stand', 'type', 'coffee', 'sleep']) { const { ctx, rects } = makeCtx(); S.drawWorker(ctx, 0, 0, { hat: 'sonnet', pose, t: 1.3 }); assert.ok(rects.length > 20, pose) }
const g = makeCtx(); S.drawWorker(g.ctx, 0, 0, { hat: 'opus', grey: true }); assert.ok(g.alphas.includes(0.55)); assert.equal(g.counts.save, g.counts.restore)
})
test('drawBubble bleibt im Bild (Spitze nahe am linken und rechten Rand, Raster 3, Breite 960)', () => {
const l = makeCtx(); S.drawBubble(l.ctx, 2, 30, 'Hallo'); assert.ok(Math.min(...l.rects.map((r) => r.x)) >= 0)
const r = makeCtx(); S.drawBubble(r.ctx, 318, 30, 'Hallo'); assert.ok(Math.max(...r.rects.map((q) => q.x + q.w)) <= 960)
})
// ---- Ergänzungen: Verhalten, das die fünf Tests oben nicht berühren ----
test('Raster gilt je Kontext (WeakMap, kein Feld am Kontext); ohne setGrid gilt k = 3', () => {
const a = makeCtx()
const b = makeCtx()
S.setGrid(a.ctx, { k: 6, ox: 1, oy: 2 })
S.px(a.ctx, 1, 1, 1, 1, '#000')
S.px(b.ctx, 1, 1, 1, 1, '#000')
assert.deepEqual([a.rects[0].x, a.rects[0].y, a.rects[0].w, a.rects[0].h], [7, 8, 6, 6])
assert.deepEqual([b.rects[0].x, b.rects[0].y, b.rects[0].w, b.rects[0].h], [3, 3, 3, 3])
assert.equal('wkGrid' in a.ctx, false)
})
test('tint: Alpha nur um die Fläche, save und restore ausgeglichen', () => {
const { ctx, rects, alphas, counts } = makeCtx()
S.tint(ctx, 0, 0, 2, 2, '#ff0000', 0.25)
assert.deepEqual([rects.length, rects[0].fill, rects[0].w], [1, '#ff0000', 6])
assert.deepEqual(alphas, [0.25])
assert.deepEqual(counts, { save: 1, restore: 1 })
})
test('label: Zahl wird zu Text, Hintergrund folgt der Ausrichtung', () => {
const n = makeCtx()
S.label(n.ctx, 42, 1, 1)
assert.equal(n.texts[0].text, '42')
const c = makeCtx()
S.label(c.ctx, 'abcd', 10, 5, { size: 12, align: 'center', bg: '#ffffff' })
// Breite 4 * 7 = 28, Mitte bei 30: Kasten von 30 - 14 - 4 bis + 28 + 8, Höhe 12 + 5
assert.deepEqual([c.rects[0].x, c.rects[0].y, c.rects[0].w, c.rects[0].h, c.rects[0].fill], [12, 13, 36, 17, '#ffffff'])
assert.equal(c.texts[0].x, 30)
})
test('label: halo zeichnet erst einen hellen Rand (strokeText), dann den Text darüber; ohne halo kein Rand', () => {
const h = makeCtx()
S.setGrid(h.ctx, { k: 3 })
S.label(h.ctx, 'Mail-Stube', 2, 1, { size: 10, color: '#445566', halo: '#ffffff' })
assert.equal(h.strokes.length, 1)
assert.deepEqual([h.strokes[0].text, h.strokes[0].style], ['Mail-Stube', '#ffffff'])
assert.deepEqual([h.strokes[0].x, h.strokes[0].y], [h.texts[0].x, h.texts[0].y], 'Rand und Text an derselben Stelle')
assert.equal(h.strokes[0].texts, 0, 'der Rand kommt vor dem Text')
assert.ok(h.strokes[0].width >= 2, 'mindestens ein Geräte-Pixel Rand rundum')
const big = makeCtx()
S.setGrid(big.ctx, { k: 6 })
S.label(big.ctx, 'Mail-Stube', 2, 1, { size: 10, halo: '#ffffff' })
assert.ok(big.strokes[0].width > h.strokes[0].width, 'der Rand wächst mit dem Raster')
const plain = makeCtx()
S.label(plain.ctx, 'Mail-Stube', 2, 1, { size: 10 })
assert.equal(plain.strokes.length, 0)
})
test('drawWorker: Schlafen zeigt ein z über dem Hut, das mit t steigt; stehen nicht', () => {
const zs = (pose, t) => {
const { ctx, rects } = makeCtx()
S.drawWorker(ctx, 20, 40, { hat: 'haiku', pose, t })
return rects.filter((r) => r.fill === S.PAL.mute)
}
const z0 = zs('sleep', 0)
const z1 = zs('sleep', 0.5)
assert.equal(z0.length, 3)
assert.equal(zs('stand', 0).length, 0)
assert.ok(z1[0].y < z0[0].y, 'steigt')
assert.ok(Math.max(...z0.map((r) => r.y + r.h)) <= (40 - 2) * 3, 'über dem Hut')
})
test('drawWorker: Kaffee zeichnet eine 3x3-Tasse und einen flackernden Dampfpixel', () => {
const run = (pose, t) => {
const { ctx, rects } = makeCtx()
S.drawWorker(ctx, 0, 0, { hat: 'sonnet', pose, t })
return rects
}
const cups = (rects) => rects.filter((r) => r.fill === S.PAL.metal2 && r.w === 9 && r.h === 9).length
const steam = (rects) => rects.filter((r) => r.fill === S.PAL.g).length
assert.equal(cups(run('coffee', 0)), 1)
assert.equal(cups(run('stand', 0)), 0)
assert.deepEqual([steam(run('coffee', 0)), steam(run('coffee', 0.4)), steam(run('stand', 0))], [1, 0, 0])
})
test('drawWorker: Grau tauscht Körper- und Hutfarben, Trefferfläche bleibt', () => {
const grey = makeCtx()
const hit = S.drawWorker(grey.ctx, 10, 20, { hat: 'opus', grey: true, carrying: true })
assert.deepEqual(hit, S.drawWorker(makeCtx().ctx, 10, 20, { hat: 'opus', carrying: true }))
const own = [S.PAL.b, S.PAL.s, S.PAL.h, S.HAT.opus[0], S.HAT.opus[1]]
assert.ok(!grey.rects.some((r) => own.includes(r.fill)))
assert.deepEqual([grey.alphas, grey.counts], [[0.55], { save: 1, restore: 1 }])
})
test('drawWorker: Auswahlpfeil nur bei selected, außerhalb der Graustufe', () => {
const ink = (opts) => {
const { ctx, rects, alphas } = makeCtx()
S.drawWorker(ctx, 0, 0, { hat: 'haiku', ...opts })
return [rects.filter((r) => r.fill === S.PAL.ink).length, alphas.length]
}
assert.deepEqual(ink({}), [0, 0])
assert.deepEqual(ink({ selected: true }), [9, 0])
assert.deepEqual(ink({ selected: true, grey: true }), [9, 1])
})
test('drawAlertMark: rotes 3x7-Ausrufezeichen mit Umriss, unten mittig bei (x, y)', () => {
const { ctx, rects } = makeCtx()
S.drawAlertMark(ctx, 50, 30)
assert.deepEqual([...new Set(rects.map((r) => r.fill))].sort(), [S.PAL.o, S.PAL.red].sort())
const red = rects.filter((r) => r.fill === S.PAL.red)
const left = Math.min(...red.map((r) => r.x))
const right = Math.max(...red.map((r) => r.x + r.w))
const top = Math.min(...red.map((r) => r.y))
const bottom = Math.max(...red.map((r) => r.y + r.h))
assert.deepEqual([right - left, bottom - top], [9, 21])
assert.ok(Math.abs((left + right) / 2 - 150) <= 1, 'mittig')
assert.equal(Math.max(...rects.map((r) => r.y + r.h)), 90)
})
test('drawBubble: Text und Maße folgen dem Raster, leerer Text zeichnet nichts, Spitze außerhalb bleibt im Bild', () => {
const a = makeCtx()
S.drawBubble(a.ctx, 50, 40, 'Hallo')
assert.deepEqual([a.texts.length, a.texts[0].text], [1, 'Hallo'])
assert.match(a.texts[0].font, /12px/)
const b = makeCtx()
S.setGrid(b.ctx, { k: 6 })
S.drawBubble(b.ctx, 50, 40, 'Hallo')
assert.match(b.texts[0].font, /24px/)
const e = makeCtx()
S.drawBubble(e.ctx, 50, 40, '')
assert.equal(e.rects.length, 0)
const o = makeCtx()
S.drawBubble(o.ctx, 330, 30, 'Hallo')
assert.ok(Math.max(...o.rects.map((q) => q.x + q.w)) <= 960)
const small = makeCtx({ width: 100 })
S.drawBubble(small.ctx, 20, 30, 'Hallo')
assert.ok(Math.max(...small.rects.map((q) => q.x + q.w)) <= 100)
})
test('drawSideBubble: links neben dem Körper, Spitze nach rechts; am linken Rand rechts daneben, Spitze nach links', () => {
const l = makeCtx()
S.drawSideBubble(l.ctx, 'Hallo', { leftX: 100, rightX: 130, y: 10 })
assert.deepEqual(l.texts.map((t) => t.text), ['Hallo'])
assert.ok(Math.max(...l.rects.map((r) => r.x + r.w)) <= 300, 'endet vor der linken Körperkante')
assert.ok(Math.max(...l.rects.map((r) => r.x + r.w)) >= 290, 'Spitze reicht bis an den Körper')
assert.equal(l.rects.length, 4)
const r = makeCtx()
S.drawSideBubble(r.ctx, 'Hallo', { leftX: 10, rightX: 40, y: 10 })
assert.ok(Math.min(...r.rects.map((q) => q.x)) >= 120, 'beginnt hinter der rechten Körperkante')
assert.ok(Math.min(...r.rects.map((q) => q.x)) <= 124, 'Spitze reicht bis an den Körper')
})
test('drawSideBubble: Oberkante bei y, Maße und Schrift folgen dem Raster, rechts in die Bildbreite geklemmt, leer zeichnet nichts', () => {
const a = makeCtx()
S.drawSideBubble(a.ctx, 'Hallo', { leftX: 100, rightX: 130, y: 10 })
const white = a.rects.find((q) => q.fill === S.PAL.w)
assert.deepEqual([white.y, white.h], [30, 22])
assert.match(a.texts[0].font, /12px/)
const b = makeCtx()
S.setGrid(b.ctx, { k: 6 })
S.drawSideBubble(b.ctx, 'Hallo', { leftX: 100, rightX: 130, y: 10 })
assert.match(b.texts[0].font, /24px/)
assert.deepEqual([b.rects.find((q) => q.fill === S.PAL.w).y, b.rects.find((q) => q.fill === S.PAL.w).h], [60, 44])
const small = makeCtx({ width: 120 })
S.drawSideBubble(small.ctx, 'Hallo', { leftX: 10, rightX: 40, y: 10 })
assert.ok(Math.max(...small.rects.map((q) => q.x + q.w)) <= 120)
const e = makeCtx()
S.drawSideBubble(e.ctx, '', { leftX: 100, rightX: 130, y: 10 })
S.drawSideBubble(e.ctx, undefined, { leftX: 100, rightX: 130, y: 10 })
assert.equal(e.rects.length, 0)
})