diff --git a/src/components/workers/engine/camera.js b/src/components/workers/engine/camera.js new file mode 100644 index 0000000..7bee579 --- /dev/null +++ b/src/components/workers/engine/camera.js @@ -0,0 +1,79 @@ +// 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 +} diff --git a/src/components/workers/engine/layout.js b/src/components/workers/engine/layout.js new file mode 100644 index 0000000..29b598d --- /dev/null +++ b/src/components/workers/engine/layout.js @@ -0,0 +1,133 @@ +// 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, Tresor, Schublade im Archiv und ein Punkt rechts außerhalb des Turms. +// 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', 88, -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 +} diff --git a/tests/unit/workersCamera.test.mjs b/tests/unit/workersCamera.test.mjs new file mode 100644 index 0000000..013b7bc --- /dev/null +++ b/tests/unit/workersCamera.test.mjs @@ -0,0 +1,119 @@ +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') +}) diff --git a/tests/unit/workersLayout.test.mjs b/tests/unit/workersLayout.test.mjs new file mode 100644 index 0000000..32cf550 --- /dev/null +++ b/tests/unit/workersLayout.test.mjs @@ -0,0 +1,120 @@ +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: 130, 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) +})