Hustler-Pixelwelt T4: reine Ableitungen (Figur, Detail, Kopfzeile, Kamera)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-22 08:04:35 +00:00
1 parent d8912ee363
commit 43b4dcbe9f
2 files changed
+1177

No files matched your search

+311
View File
@@ -0,0 +1,311 @@
// Reine Ableitungen der Hustler-Pixelwelt (Spec E5-E7, E10): Figur nach Status, Detailansicht, Kopfzeile, Kamera.
// Kein React, kein DOM, kein Appwrite: unter Node testbar (loadModule). Klassen heißen genau wie in
// src/styles/hustler/hochhaus.css (alle mit hk- davor). Keine Beispieldaten: die Aufgaben-Texte sind UI-Text.
import { FLOOR_TITLES, MODEL_IDS, ROLES, ROOM_TITLES, SEARCH_ROOMS, hatForWorker, roleOf } from './workersRoster.js'
import { counterLines, formatCount, formatEur, formatUsd, kpiTiles, tokenTotal, traceRows } from './workersView.js'
import { ETAGEN_SPALTE, HAUS, WELT } from './hustlerWelt.js'
const DASH = '–'
const OVERSEER_ID = 'otto'
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
const isNum = (n) => typeof n === 'number' && Number.isFinite(n)
const text = (v) => (typeof v === 'string' ? v.trim() : '')
// ---------------------------------------------------------------------------------------------------------------
// Namen und Modelle
/** Hut-Namen der Sprites je Hut des Rosters (hatForWorker: haiku/sonnet/opus/overseer). */
export const HUT = Object.freeze({ haiku: 'muetze', sonnet: 'kappe', opus: 'krone', overseer: 'overseer' })
const MODELL_KURZ = Object.freeze({ opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku' })
const MODELL_NAME = Object.freeze({ opus: 'Claude Opus 5.5', sonnet: 'Claude Sonnet 5.5', haiku: 'Claude Haiku 5.5' })
/** Anzeige-Name: otto heißt in der Oberfläche „Overseer“, sonst Roster-Name, sonst die Id selbst. */
export function anzeigeName(workerId) {
if (workerId === OVERSEER_ID) return 'Overseer'
const r = roleOf(workerId)
if (r) return r.name
return typeof workerId === 'string' ? workerId : ''
}
/** 'Opus' | 'Sonnet' | 'Haiku' nach dem Modell der Rolle; unbekannte Kennung '–'. */
export function modellKurz(workerId) {
const r = roleOf(workerId)
return r && Object.hasOwn(MODELL_KURZ, r.model) ? MODELL_KURZ[r.model] : DASH
}
/** 'Claude Opus 5.5' usw. aus Kurzname ('opus') oder voller Modell-Id ('claude-opus-5-5'); sonst '–'. */
export function modellName(model) {
if (typeof model !== 'string') return DASH
const m = model.trim().toLowerCase()
if (Object.hasOwn(MODELL_NAME, m)) return MODELL_NAME[m]
const kurz = Object.keys(MODEL_IDS).find((k) => MODEL_IDS[k] === m)
return kurz ? MODELL_NAME[kurz] : DASH
}
// ---------------------------------------------------------------------------------------------------------------
// Status
const STATUS_BEKANNT = new Set(['arbeitet', 'wartet', 'leerlauf', 'fehler'])
/** Anzeige-Status des Zustandseintrags: nicht angeschlossen -> 'grau', sonst der Dienst-Status, alles andere 'leerlauf'. */
export function statusVon(eintrag) {
if (!isRecord(eintrag)) return 'leerlauf'
if (eintrag.connected === false) return 'grau'
return typeof eintrag.status === 'string' && STATUS_BEKANNT.has(eintrag.status) ? eintrag.status : 'leerlauf'
}
/** Anzeige je Status: text (Detailansicht), aria (Figuren-Label), farbe (Punkt neben dem Status). */
export const STATUS_ANZEIGE = Object.freeze({
arbeitet: Object.freeze({ text: 'Arbeitet', aria: 'arbeitet', farbe: '#4ade5a' }),
wartet: Object.freeze({ text: 'Wartet auf Auftrag', aria: 'wartet auf Auftrag', farbe: '#ffd25a' }),
leerlauf: Object.freeze({ text: 'Leerlauf', aria: 'Leerlauf', farbe: '#9aa6d6' }),
grau: Object.freeze({ text: 'Noch nicht angeschlossen', aria: 'noch nicht angeschlossen', farbe: '#a4a2ad' }),
fehler: Object.freeze({ text: 'Fehler', aria: 'Fehler', farbe: '#ff5a5a' }),
})
/** Pose des Sprites je Status (grau und Fehler stehen). */
export const POSE_NACH_STATUS = Object.freeze({ arbeitet: 'arbeiten', wartet: 'warten', leerlauf: 'schlafen', grau: 'nicht', fehler: 'nicht' })
// ---------------------------------------------------------------------------------------------------------------
// Figur im Haus
/**
* Alles, was die Figur im Haus braucht (Spec E5): Sprite-Klasse, Zusatz-Symbole, graue Raum-Ebene, Label.
* `eintrag` = Zustandseintrag { status, task, lastTs, connected, counters } aus indexWorkers; paused = alles steht still.
*/
export function figurZustand(workerId, eintrag, { paused = false } = {}) {
const status = statusVon(eintrag)
const ovs = workerId === OVERSEER_ID
const hut = HUT[hatForWorker(workerId)]
const pose = ovs ? 'overseer' : POSE_NACH_STATUS[status]
const rolle = roleOf(workerId)
const raum = rolle ? rolle.room : null
const modell = modellKurz(workerId)
return {
status,
hut,
pose,
klasse: ovs ? 'hk-spr hk-ovs-klein' : `hk-spr hk-fig-${hut}-${pose}`,
punkte: status === 'wartet',
zzz: status === 'leerlauf',
bildschirm: status === 'arbeitet' && !ovs,
radar: workerId === 'rita' && status === 'arbeitet',
alarm: status === 'fehler',
raum,
raumGrau: status === 'grau' && raum !== null && SEARCH_ROOMS.includes(raum),
gedimmt: ovs && status !== 'arbeitet',
steht: paused === true,
label: [anzeigeName(workerId), modell === DASH ? '' : modell, STATUS_ANZEIGE[status].aria].filter((t) => t !== '').join(', '),
}
}
// ---------------------------------------------------------------------------------------------------------------
// Detailansicht
/** Aufgabe je Hustler (UI-Text der Zeichenfläche, wörtlich). */
export const AUFGABEN = Object.freeze({
otto: 'Plant die Nacht, verteilt die Aufträge, achtet aufs Budget und schreibt den Morgenbericht.',
rita: 'Sucht im offenen Netz über Suchmaschinen nach frischen „Suche Webdesigner“-Posts.',
kim: 'Liest die Suchauftrags-Mails von Kleinanzeigen und ähnlichen Portalen.',
pia: 'Liest Projekt-Alerts und RSS der Portale (freelancermap, freelance.de und weitere).',
finn: 'Durchsucht Foren und Frage-Seiten, nur wo es erlaubt ist.',
ali: 'Branchen-Scout für Ärzte und Praxen ohne oder mit veralteter Website.',
hanna: 'Branchen-Scout für Handwerksbetriebe ohne oder mit veralteter Website.',
sven: 'Branchen-Scout für Selbstständige ohne oder mit veralteter Website.',
lea: 'Branchen-Scout für Läden und Gastro ohne oder mit veralteter Website.',
bea: 'Bewertet Anfragen und Kandidaten und verteilt Top 3 und Rest.',
max: 'Schreibt Mails und ist der einzige Absender: Erstmail, Nachfassen und Antworten.',
clara: 'Schreibt nur Antworten auf Anfragen (Plattform ins Pult, Mail an Max).',
nora: 'Liest und sortiert alle eingehenden Antworten.',
theo: 'Schreibt die Nachrichten rund um den Kauf (als Entwurf).',
bob: 'Baut Vorschau-Websites, nur nach einem Ja.',
bianca: 'Baut Vorschau-Websites, nur nach einem Ja.',
ben: 'Baut Vorschau-Websites, nur nach einem Ja.',
paul: 'Prüft jede Vorschau am Desktop und am Handy und gibt sie frei oder schickt sie zurück.',
arne: 'Führt das Nie-doppelt-Register und die Bot-Lead-Liste.',
})
function aufgabeVon(workerId, trace, rolle) {
if (typeof workerId === 'string' && Object.hasOwn(AUFGABEN, workerId)) return AUFGABEN[workerId]
const info = isRecord(trace) && isRecord(trace.worker) ? trace.worker : null
return text(info && info.role) || text(rolle && rolle.role) || DASH
}
function heuteVon(trace, eintrag) {
const today = isRecord(trace) && isRecord(trace.today) ? trace.today : null
const zaehler = today && isRecord(today.counters) ? today.counters : isRecord(eintrag) ? eintrag.counters : null
const zeilen = counterLines(zaehler).map((c) => `${c.value} ${c.label}`)
return zeilen.length > 0 ? zeilen.join(' · ') : DASH
}
/**
* Felder der Detailansicht (Spec E10). trace = Antwort von api.trace (oder null/kaputt), now = Uhr für die Spur.
* Overseer steht auf der Brücke, ein nicht angeschlossener Hustler in der Stecker-Szene, alle anderen im Büro.
*/
export function detailModell(workerId, eintrag, trace, now) {
const status = statusVon(eintrag)
const anzeige = STATUS_ANZEIGE[status]
const ovs = workerId === OVERSEER_ID
const rolle = roleOf(workerId)
const info = isRecord(trace) && isRecord(trace.worker) ? trace.worker : null
const modelId = text(info && info.modelId) !== '' ? info.modelId : rolle ? rolle.model : null
const today = isRecord(trace) && isRecord(trace.today) ? trace.today : null
const hut = HUT[hatForWorker(workerId)]
const index = ROLES.findIndex((r) => r.workerId === workerId)
return {
id: workerId,
name: anzeigeName(workerId),
status,
statusText: anzeige.text,
statusFarbe: anzeige.farbe,
modell: modellName(modelId),
etageRaum: rolle ? `${FLOOR_TITLES[rolle.floor]} · ${ROOM_TITLES[rolle.room]}` : DASH,
aufgabe: aufgabeVon(workerId, trace, rolle),
gerade: text(isRecord(eintrag) ? eintrag.task : null) || DASH,
heute: heuteVon(trace, eintrag),
kosten: formatUsd(today ? today.costUsd : null),
tokens: formatCount(today ? tokenTotal(today.tokens) : null),
spur: traceRows(isRecord(trace) ? trace.events : null, now),
nummer: `${index >= 0 ? index + 1 : DASH} / ${ROLES.length}`,
szene: ovs ? 'hk-sz hk-sz-bruecke' : status === 'grau' ? 'hk-sz hk-sz-nicht' : 'hk-sz hk-sz-buero',
figur: ovs ? 'hk-spr hk-gr-overseer' : `hk-spr hk-gr-${hut}-${POSE_NACH_STATUS[status]}`,
overlays: {
monitor: status === 'arbeitet' && !ovs,
dampf: status === 'wartet',
zzz: status === 'leerlauf',
wirbel: ovs,
schild: status === 'grau',
alarm: status === 'fehler',
},
}
}
/** Nächster (schritt > 0) oder vorheriger Hustler in Roster-Reihenfolge mit Umlauf; unbekannte Kennung -> der erste. */
export function blaettern(workerId, schritt) {
const n = ROLES.length
const i = ROLES.findIndex((r) => r.workerId === workerId)
if (i < 0) return ROLES[0].workerId
const d = isNum(schritt) ? Math.trunc(schritt) : 0
return ROLES[(((i + d) % n) + n) % n].workerId
}
// ---------------------------------------------------------------------------------------------------------------
// Kopfzeile (HUD)
const API_TITEL = 'API-Kosten heute'
const HUD_ZAHLEN = Object.freeze([
Object.freeze({ key: 'abos', icon: 'hk-ic hk-stern', titel: 'Neue Abos (Monat)', farbe: '#ffd25a', feld: 'newAbosMonth' }),
Object.freeze({ key: 'deals', icon: 'hk-ic hk-pokal', titel: 'Deals (Monat)', farbe: '#ff9f43', feld: 'dealsMonth' }),
Object.freeze({ key: 'threads', icon: 'hk-ic hk-blase', titel: 'Gespräche offen', farbe: '#4fd1e8', feld: 'openThreads' }),
Object.freeze({ key: 'pult', icon: 'hk-ic hk-glocke', titel: 'Wartet auf dich', farbe: '#ff6fae', feld: 'pultOpen' }),
Object.freeze({ key: 'botleads', icon: 'hk-ic hk-robo', titel: 'Bot-Leads gesamt', farbe: '#a78bfa', feld: 'botLeads' }),
])
/**
* Werte der Kopfzeile aus state.kpis: grüne und rote Münze, fünf Icons mit Zahl. Fehlende oder kaputte Werte
* ergeben '–', nie NaN. Das Minus der Kosten ist ein ASCII-Bindestrich (die Pixelschrift hat kein U+2212).
*/
export function hudWerte(kpis) {
const k = isRecord(kpis) ? kpis : {}
const stufe = kpiTiles(k).find((t) => t.key === 'api').tone
const einnahmen = isNum(k.mrrNewEur) && k.mrrNewEur >= 0 ? `+${formatEur(k.mrrNewEur)}/m` : DASH
const heute = isNum(k.apiTodayUsd) && k.apiTodayUsd >= 0 ? k.apiTodayUsd : null
const kosten = heute === null ? DASH : heute === 0 ? formatUsd(0) : `-${formatUsd(heute)}`
const grenze = heute !== null && isNum(k.dailyBudgetUsd) ? ` von ${formatUsd(k.dailyBudgetUsd)}` : ''
return {
einnahmen: { text: einnahmen, titel: 'Eingenommen pro Monat', sr: 'Eingenommen:' },
api: { text: kosten, titel: `${API_TITEL}: ${heute === null ? DASH : formatUsd(heute)}${grenze}`, sr: `${API_TITEL}:`, stufe },
zahlen: HUD_ZAHLEN.map((z) => ({ key: z.key, icon: z.icon, titel: z.titel, text: formatCount(k[z.feld]), farbe: z.farbe })),
}
}
function zaehlerSchild(n, einzahl, mehrzahl) {
if (!isNum(n) || n < 0) return null
return `${formatCount(n)} ${n === 1 ? einzahl : mehrzahl}`
}
/** Schilder im Haus: Briefkasten-Zähler (bei 0 weg), Tresor- und Keller-Schild; fehlende Werte ergeben null. */
export function schilder(kpis) {
const k = isRecord(kpis) ? kpis : {}
return {
briefkasten: isNum(k.pultOpen) && k.pultOpen > 0 ? formatCount(k.pultOpen) : null,
tresor: zaehlerSchild(k.dealsMonth, 'Deal', 'Deals'),
keller: zaehlerSchild(k.botLeads, 'Bot-Lead', 'Bot-Leads'),
}
}
// ---------------------------------------------------------------------------------------------------------------
// Kamera (Formeln aus logik.js der Zeichenfläche). g = Größe des Sichtfensters { w, h }; Ergebnis { s, x, y }:
// Welt-Pixel mal s plus Verschiebung x/y. Ungültige Eingaben (0, NaN) fallen auf die Entwurfsgröße zurück.
/** Größte Vergrößerung. */
export const ZOOM_MAX = 5
/** Größe des Sichtfensters, wenn noch keine gemessen ist (wie im Entwurf). */
export const SICHT_STANDARD = Object.freeze({ w: 1280, h: 720 })
const RAND_SEITE = 48 // „ganzes Haus“: so viel Luft links und rechts zusammen
const RAND_UNTEN = 16 // „ganzes Haus“: Luft unter dem Haus
const ETAGE_SEITE = 96 // Etage heranzoomen: Luft links und rechts zusammen
const ETAGE_BREITE = ETAGEN_SPALTE.w + 48 // Breite, die eine Etage im Bild füllen soll (Spalte plus Rand, 560)
const ETAGE_ZOOM_MAX = 3
const ETAGE_HOEHE = 3.4 // Etagenhöhe mal dieser Faktor passt in die Höhe unter der Kopfzeile
const ETAGE_HOEHE_RAND = 40
const OBEN_FREI = 0.35 // so viel der Höhe darf oben über der Welt frei bleiben (Himmel)
function sicht(g) {
return isRecord(g) && isNum(g.w) && g.w > 0 && isNum(g.h) && g.h > 0 ? g : SICHT_STANDARD
}
/** Kleinster Zoom: die Welt füllt das Fenster ganz („cover“). */
export function kameraMinS(g) {
const v = sicht(g)
return Math.max(v.w / WELT.w, v.h / WELT.h)
}
/** Kamera in die Grenzen zwingen: Zoom zwischen „cover“ und 5, links/rechts/unten kein leerer Rand, oben höchstens 35 %. */
export function kameraGrenzen(kamera, g) {
const v = sicht(g)
const k = isRecord(kamera) ? kamera : {}
const min = kameraMinS(v)
const s = Math.min(ZOOM_MAX, Math.max(min, isNum(k.s) ? k.s : min))
const x = Math.min(0, Math.max(v.w - WELT.w * s, isNum(k.x) ? k.x : 0))
const y = Math.min(v.h * OBEN_FREI, Math.max(v.h - WELT.h * s, isNum(k.y) ? k.y : 0))
return { s, x, y }
}
/** „Ganzes Haus“: das Haus mittig unter der Kopfzeile, so groß wie es passt (nie kleiner als „cover“). */
export function kameraPassend(g) {
const v = sicht(g)
const s = Math.max(kameraMinS(v), Math.min((v.w - RAND_SEITE) / HAUS.w, (v.h - WELT.hud - RAND_UNTEN) / HAUS.h))
const x = (v.w - HAUS.w * s) / 2 - HAUS.x * s
const y = WELT.hud + (v.h - WELT.hud - RAND_UNTEN - HAUS.h * s) / 2 - HAUS.y * s
return kameraGrenzen({ s, x, y }, v)
}
/**
* Auf neuen Zoom `neuS` um den Punkt (mx, my) im Fenster: der Weltpunkt unter dem Zeiger bleibt dort (soweit die
* Grenzen es erlauben). Ohne gültige bisherige Kamera (s <= 0, NaN) bleibt nur „ganzes Haus“.
*/
export function kameraZoomAuf(alt, mx, my, neuS, g) {
const v = sicht(g)
if (!isRecord(alt) || !isNum(alt.s) || alt.s <= 0 || !isNum(alt.x) || !isNum(alt.y)) return kameraPassend(v)
const px = isNum(mx) ? mx : v.w / 2
const py = isNum(my) ? my : v.h / 2
const n = kameraGrenzen({ s: neuS, x: 0, y: 0 }, v)
const f = n.s / alt.s
return kameraGrenzen({ s: n.s, x: px - (px - alt.x) * f, y: py - (py - alt.y) * f }, v)
}
/** Eine Etage (Klickbereich oben..unten in Weltpixeln) mittig heranzoomen, höchstens 3-fach. */
export function kameraEtage(oben, unten, g) {
const v = sicht(g)
const hoehe = isNum(oben) && isNum(unten) && unten > oben ? unten - oben : 1
const mitte = isNum(oben) && isNum(unten) ? (oben + unten) / 2 : 0
const s = Math.max(kameraMinS(v), Math.min(ETAGE_ZOOM_MAX, (v.w - ETAGE_SEITE) / ETAGE_BREITE, (v.h - WELT.hud - ETAGE_HOEHE_RAND) / (hoehe * ETAGE_HOEHE)))
return kameraGrenzen({ s, x: v.w / 2 - (ETAGEN_SPALTE.x + ETAGEN_SPALTE.w / 2) * s, y: WELT.hud / 2 + v.h / 2 - mitte * s }, v)
}