diff --git a/src/components/workers/engine/director.js b/src/components/workers/engine/director.js index 60fc464..02432a1 100644 --- a/src/components/workers/engine/director.js +++ b/src/components/workers/engine/director.js @@ -1,17 +1,20 @@ -// Regie des Hustler-Hochhauses, Teil 1: Agenten, Snapshot ohne Animation, Posen, Leerlauf, Zeichnen. -// Portiert aus entwuerfe/scene-turm.js (Agenten Z. 596-625, Update Z. 864-905, Zeichnen Z. 925-960). -// Schritte, Aufzugfahrten, Rückstau-Kappung und Wachhund ergänzt P2-T13. Kein React, kein Appwrite, kein window/document. +// 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, cutBubble } from '../../../lib/workersEvents.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 Ereignisse (T13) -export const GROUPS_PER_AGENT = 4 // je Worker höchstens so viele wartende Gruppen (T13) +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 (T13) +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 @@ -22,6 +25,17 @@ 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) @@ -53,13 +67,47 @@ 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) -/* createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Math.random }) → { agents, byId, applyState(state), update(dt), pending(), boxes(), bubble(agent, text, dur) } +// 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). - update(dt) tut bei look.paused nichts und läuft sonst ohne Obergrenze für dt (flush in T13 ruft es mit 0,25). rnd streut die Wipp-Phase. */ + 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) @@ -91,12 +139,14 @@ export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Ma bob: 0, idleT: 0, cheer: 0, - q: [], - cur: null, - stepT: 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) @@ -104,7 +154,11 @@ export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Ma byId[a.id] = a } - // Auf den Heimatplatz stellen (erster Snapshot, T13: reset) + 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 @@ -127,16 +181,24 @@ export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Ma } } - // Eintrag aus indexWorkers (oder null = fehlt im Zustand) auf den Agenten übertragen - function adopt(a, w) { + // 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)) { - a.task = w ? w.task : '' - a.pose = poseFor(a) + if (busy(a)) { + lagging.add(a.id) + return } + lagging.delete(a.id) + a.task = w ? w.task : '' + a.pose = poseFor(a) } function applyKpis(kpis) { @@ -153,8 +215,8 @@ export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Ma } function applyState(state) { - const workers = indexWorkers(state) - for (const a of agents) adopt(a, Object.hasOwn(workers, a.id) ? workers[a.id] : null) + workers = indexWorkers(state) + for (const a of agents) adopt(a, entryOf(a)) if (first) { first = false for (const a of agents) placeHome(a) @@ -176,7 +238,326 @@ export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Ma 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) @@ -196,8 +577,8 @@ export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Ma } } - function update(dt) { - if (look.paused || !Number.isFinite(dt) || dt <= 0) return + // 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) @@ -211,12 +592,45 @@ export function createDirector({ layout, lift, fx, look, roles = ROLES, rnd = Ma } } + 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 })) } - return { agents, byId, applyState, update, pending: () => 0, boxes, bubble } + const debug = () => ({ door: door.slice(), groups: pending() }) + + return { agents, byId, applyState, pushEvents, update, flush, reset, pending, debug, boxes, bubble } } // ---------- Zeichnen ---------- diff --git a/tests/unit/workersDirector.test.mjs b/tests/unit/workersDirector.test.mjs index f34ef9a..7b3b1c5 100644 --- a/tests/unit/workersDirector.test.mjs +++ b/tests/unit/workersDirector.test.mjs @@ -2,12 +2,12 @@ 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 } from './helpers/workersFixtures.mjs' +import { IDS, makeState, ev } from './helpers/workersFixtures.mjs' -// Regie 1: Agenten, Snapshot ohne Animation, Posen, Leerlauf, Zeichnen (Quelle: entwuerfe/scene-turm.js) +// 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 } = await loadModule('src/components/workers/engine/lift.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') @@ -231,3 +231,410 @@ test('drawAgents: die Blase weicht dem „!“ und dem Auswahlpfeil aus', () => 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 +})