From 8555453a7442239e8002e203ecfac03bbdf19ade Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:29:57 +0000 Subject: [PATCH] =?UTF-8?q?Workers=20P2:=20R=C3=A4ume=20nach=20kind,=20Pok?= =?UTF-8?q?al-Seiten,=20graue=20R=C3=A4ume,=20Geb=C3=A4udeh=C3=BClle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/components/workers/engine/building.js | 114 ++++++++ src/components/workers/engine/rooms.js | 306 ++++++++++++++++++++++ tests/unit/workersRooms.test.mjs | 240 +++++++++++++++++ 3 files changed, 660 insertions(+) create mode 100644 src/components/workers/engine/building.js create mode 100644 src/components/workers/engine/rooms.js create mode 100644 tests/unit/workersRooms.test.mjs diff --git a/src/components/workers/engine/building.js b/src/components/workers/engine/building.js new file mode 100644 index 0000000..81a35b0 --- /dev/null +++ b/src/components/workers/engine/building.js @@ -0,0 +1,114 @@ +// 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 }) +} diff --git a/src/components/workers/engine/rooms.js b/src/components/workers/engine/rooms.js new file mode 100644 index 0000000..e42d82a --- /dev/null +++ b/src/components/workers/engine/rooms.js @@ -0,0 +1,306 @@ +// 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 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 + 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 ---------- + +function drawRoom(ctx, room, floor, index, t, look) { + // Räume ohne Worker (pult, tresor) werden nie grau + const grey = room.workerId != null && look.greyRooms.has(room.id) + 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, + } + if (!grey) 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) + // Das Schild steht oben mittig und verdrängt den Titel; Name und Branche erkennt man an Möbeln und Icon + if (!grey) label(ctx, room.title, room.x + 2, floor.top + 1, { size: 10, color: PAL.mute, weight: 500 }) + if (Object.hasOwn(ROOM_DRAWERS, room.kind)) ROOM_DRAWERS[room.kind](ctx, r) + if (grey) { + tint(ctx, room.x, floor.top, room.w, floor.h, GREY_TINT, GREY_ALPHA) + const w = Math.min(room.w - 2, SIGN_MAX_W) + drawSign(ctx, room.x + (room.w - w) / 2, floor.top + 1, w, SIGN_TEXT) + } +} + +// Alle Räume aller Etagen. Vorher: drawOutside, drawTower. Nachher: Aufzug hinten, Figuren, Aufzug vorn. +export function drawRooms(ctx, layout, t, look) { + for (const floor of layout.floors) floor.rooms.forEach((room, index) => drawRoom(ctx, room, floor, index, t, look)) +} diff --git a/tests/unit/workersRooms.test.mjs b/tests/unit/workersRooms.test.mjs new file mode 100644 index 0000000..f13faee --- /dev/null +++ b/tests/unit/workersRooms.test.mjs @@ -0,0 +1,240 @@ +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())) +})