Workers P2: Regie 2 (Schritte, Aufzugfahrten, Rückstau-Kappung, Wachhund)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-18 03:14:55 +00:00
1 parent e69c4355bd
commit ad8ee9afe2
2 files changed
+848 -27

No files matched your search

+438 -24
View File
@@ -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 ----------
+410 -3
View File
@@ -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
})