From fb331de1a613fd451d575db6019ba58e484da362 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 02:16:47 +0000 Subject: [PATCH] Workers P2: Aufzug mit Abbruch und Reset Co-Authored-By: Claude Sonnet 5.5 --- src/components/workers/engine/lift.js | 169 +++++++++++++++++++ tests/unit/workersLift.test.mjs | 228 ++++++++++++++++++++++++++ 2 files changed, 397 insertions(+) create mode 100644 src/components/workers/engine/lift.js create mode 100644 tests/unit/workersLift.test.mjs diff --git a/src/components/workers/engine/lift.js b/src/components/workers/engine/lift.js new file mode 100644 index 0000000..142430e --- /dev/null +++ b/src/components/workers/engine/lift.js @@ -0,0 +1,169 @@ +// Aufzug des Hustler-Hochhauses: Warteschlange, Zustandsautomat (Anfahren, Einsteigen, Fahren, Aussteigen) und Kabine. +// Portiert aus entwuerfe/scene-turm.js (Kabine Z. 316-330, Zustandsautomat Z. 466-492). Neu: cancel/reset geben die Kabine +// frei (die Regie bricht Gruppen ab, ohne dass der Aufzug hängen bleibt), ungültige Anfragen kommen abgebrochen zurück. +// Die Etagenlampen zeichnet building.js über at(i). Kein React, kein Appwrite, kein window/document. + +import { cabTop } from './layout.js' +import { px, tint, PAL } from './sprites.js' + +// Maße in Weltkoordinaten: cabWx = linke Kante des Fahrgasts in der Kabine (12 breiter Körper mittig in der 20 breiten Kabine), +// exitX = Ziel des ersten Schritts nach dem Aussteigen, slot0/slotStep = Wartepunkte vor dem Schacht (wer zuerst kommt, steht links), +// speed = Einheiten je Sekunde, cabH = Kabinenhöhe (gleich der in layout.js) +export const LIFT = Object.freeze({ cabWx: 22, exitX: 50, slot0: 52, slotStep: 14, speed: 110, cabH: 21 }) + +const CAB_X = 18 // linke Kante der Kabine (20 breit) +const CAB_W = 20 +const ROPE_X = 28 +const ROPE_TOP = 9 // das Seil hängt unter der Seilrolle am Schachtkopf +const RIDER_DY = 7 // Fahrgast: obere Kante des Körpers 7 unter der Kabinenoberkante (= Standhöhe auf der Etage) +const CLOSE_S = 0.3 // Türen schließen, bevor die Fahrt beginnt +const NEAR = 1.5 // at(i): Kabine gilt als auf der Etage, wenn sie höchstens so weit davon entfernt ist +const START_FLOOR = 2 + +const FRAME = PAL.metal2 +const CAB_BACK = '#f1f3f8' +const CAB_RAIL = '#cfd5df' +const CAB_BASE = '#e1e5ec' +const CAB_LAMP = '#ffe9a8' + +/* createLift(layout) → { y, state, request(agent, from, to), update(dt), cancel(agent), reset(), at(floorIndex), drawBack(ctx), drawFront(ctx) } + y = Oberkante der Kabine, state = idle | toPick | boarding | closing | riding | unloading. + request legt eine Anfrage { a, from, to, st } hinten an (FIFO); st läuft queued -> open -> boarded -> arrived -> out, außerdem cancelled. + boarded und out setzt der Fahrgast (die Regie), alle anderen Übergänge der Aufzug. Während der Fahrt (closing, riding) folgt der Agent + der Kabine: a.y = y + 7, a.x = cabWx, a.frame = 0. + Eine Anfrage mit ungültiger Etage (kein ganzer Index im Layout) oder ohne Agenten-Objekt kommt sofort als cancelled zurück und wird nie bedient. */ +export function createLift(layout) { + const floorCount = layout.floors.length + const isFloor = (i) => Number.isInteger(i) && i >= 0 && i < floorCount + + const lift = { y: cabTop(layout, Math.min(START_FLOOR, floorCount - 1)), state: 'idle' } + let queue = [] // wartende Anfragen, vorne die nächste + let req = null // laufende Anfrage + let closeT = 0 + + // Kabine frei: keine laufende Anfrage, Leerlauf (die Kabine bleibt, wo sie ist) + function release() { + req = null + lift.state = 'idle' + closeT = 0 + } + + // Fährt um höchstens speed * dt auf ty zu; true = angekommen + function cabMove(ty, dt) { + const d = ty - lift.y + const s = LIFT.speed * dt + if (Math.abs(d) <= s) { + lift.y = ty + return true + } + lift.y += d > 0 ? s : -s + return false + } + + // Der Fahrgast steht in der Kabine + function follow() { + if (!req) return + req.a.y = lift.y + RIDER_DY + req.a.x = LIFT.cabWx + req.a.frame = 0 + } + + lift.request = function request(a, from, to) { + const r = { a, from, to, st: 'queued' } + if (!a || typeof a !== 'object' || !isFloor(from) || !isFloor(to)) { + r.st = 'cancelled' + return r + } + queue.push(r) + return r + } + + lift.update = function update(dt) { + if (!Number.isFinite(dt) || dt <= 0) return + // von außen abgebrochene laufende Anfrage: Kabine frei, kein Festhängen in boarding + if (req && req.st === 'cancelled') release() + if (lift.state === 'idle') { + while (queue.length && queue[0].st === 'cancelled') queue.shift() + if (queue.length) { + req = queue.shift() + lift.state = 'toPick' + } + } + if (lift.state === 'toPick') { + if (cabMove(cabTop(layout, req.from), dt)) { + req.st = 'open' + lift.state = 'boarding' + } + } else if (lift.state === 'boarding') { + if (req.st === 'boarded') { + lift.state = 'closing' + closeT = 0 + } + } else if (lift.state === 'closing') { + closeT += dt + follow() + if (closeT > CLOSE_S) lift.state = 'riding' + } else if (lift.state === 'riding') { + // erst fahren, dann mitnehmen: der Fahrgast hinkt der Kabine nicht um einen Schritt hinterher (die Vorlage tut es) + const arrived = cabMove(cabTop(layout, req.to), dt) + follow() + if (arrived) { + req.st = 'arrived' + lift.state = 'unloading' + } + } else if (lift.state === 'unloading') { + if (req.st === 'out') release() + } + } + + // Alle Anfragen des Agenten abbrechen; steckt er in der Kabine, ist sie danach frei (der Agent wird nicht mehr bewegt). + // Ein bereits ausgestiegener Fahrgast (out) bleibt out. + lift.cancel = function cancel(agent) { + const keep = [] + for (const r of queue) { + if (r.a === agent) r.st = 'cancelled' + else keep.push(r) + } + queue = keep + if (req && req.a === agent) { + if (req.st !== 'out') req.st = 'cancelled' + release() + } + } + + // Alle wartenden und die laufende Anfrage abbrechen (Schnappschuss, Neuaufbau der Szene); die Kabine bleibt stehen + lift.reset = function reset() { + for (const r of queue) r.st = 'cancelled' + queue = [] + if (req && req.st !== 'out') req.st = 'cancelled' + release() + } + + // Steht die Kabine auf Etage i? (Etagenlampe grün); ungültiger Index -> false + lift.at = function at(i) { + return isFloor(i) && Math.abs(lift.y - cabTop(layout, i)) < NEAR + } + + // Seil und Rückwand: vor den Figuren zeichnen + lift.drawBack = function drawBack(ctx) { + const cy = Math.round(lift.y) + px(ctx, ROPE_X, ROPE_TOP, 1, cy - ROPE_TOP, FRAME) + px(ctx, CAB_X, cy, CAB_W, LIFT.cabH, CAB_BACK) + px(ctx, CAB_X + 1, cy + 11, CAB_W - 2, 1, CAB_RAIL) + px(ctx, CAB_X + 1, cy + 18, CAB_W - 2, 2, CAB_BASE) + } + + // Glasschleier, Rahmen, Lampe und Aufhängung: nach den Figuren zeichnen + lift.drawFront = function drawFront(ctx) { + const cy = Math.round(lift.y) + tint(ctx, CAB_X + 1, cy + 1, CAB_W - 2, LIFT.cabH - 2, '#ffffff', 0.18) + px(ctx, CAB_X, cy, CAB_W, 1, FRAME) + px(ctx, CAB_X, cy + LIFT.cabH - 1, CAB_W, 1, FRAME) + px(ctx, CAB_X, cy, 1, LIFT.cabH, FRAME) + px(ctx, CAB_X + CAB_W - 1, cy, 1, LIFT.cabH, FRAME) + px(ctx, 24, cy + 1, 8, 1, CAB_LAMP) + px(ctx, 26, cy - 2, 4, 2, FRAME) + } + + return lift +} diff --git a/tests/unit/workersLift.test.mjs b/tests/unit/workersLift.test.mjs new file mode 100644 index 0000000..27566a6 --- /dev/null +++ b/tests/unit/workersLift.test.mjs @@ -0,0 +1,228 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' +import { makeCtx } from './helpers/fakeCtx.mjs' + +// Aufzug: Warteschlange, Zustandsautomat, Abbruch/Reset und Kabine (Quelle: entwuerfe/scene-turm.js) +const { createLift, LIFT } = await loadModule('src/components/workers/engine/lift.js') +const { buildLayout, cabTop } = await loadModule('src/components/workers/engine/layout.js') +const { PAL } = await loadModule('src/components/workers/engine/sprites.js') +const { FLOORS } = await loadModule('src/lib/workersRoster.js') + +const layout = buildLayout() +function until(lift, fn, max = 12) { let t = 0; while (!fn() && t < max) { lift.update(0.05); t += 0.05 } return t } +// Schritt für Schritt bis zur Bedingung, mit Sicherung gegen Endlosschleifen bei einem Fehler im Aufzug +function stepUntil(step, fn, max = 400) { for (let i = 0; i < max && !fn(); i++) step(); assert.ok(fn(), 'Bedingung nicht erreicht') } +test('Fahrt 1 → 8: queued, open, boarded, arrived, out; Fahrgast folgt der Kabine; danach idle', () => { + const lift = createLift(layout), a = { x: 0, y: 0, frame: 1 }, req = lift.request(a, 1, 8) + assert.equal(req.st, 'queued') + let t = until(lift, () => req.st === 'open'); assert.ok(lift.at(1)); assert.equal(lift.state, 'boarding') + req.st = 'boarded'; t += until(lift, () => req.st === 'arrived'); assert.ok(lift.at(8)); assert.equal(a.y, cabTop(layout, 8) + 7); assert.equal(a.x, LIFT.cabWx) + assert.ok(t < 12); req.st = 'out'; lift.update(0.05); assert.equal(lift.state, 'idle') +}) +test('FIFO: zweite Anfrage wartet bis die erste ausgestiegen ist', () => { + const lift = createLift(layout), r1 = lift.request({}, 1, 4), r2 = lift.request({}, 5, 2) + until(lift, () => r1.st === 'open'); r1.st = 'boarded'; until(lift, () => r1.st === 'arrived'); assert.equal(r2.st, 'queued') + r1.st = 'out'; until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open') +}) +test('cancel und reset geben die Kabine frei (kein Festhängen)', () => { + const lift = createLift(layout), a = {}, r1 = lift.request(a, 7, 1), r2 = lift.request({}, 2, 3) + lift.update(0.05); lift.cancel(a); assert.equal(r1.st, 'cancelled'); until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open') + lift.reset(); assert.equal(r2.st, 'cancelled'); assert.equal(lift.state, 'idle') +}) + +// ---- Ergänzungen: Verhalten, das die drei Tests oben nicht berühren ---- + +test('LIFT-Werte wie vereinbart und cabH stimmt mit der Kabinenhöhe des Layouts überein', () => { + assert.deepEqual({ ...LIFT }, { cabWx: 22, exitX: 50, slot0: 52, slotStep: 14, speed: 110, cabH: 21 }) + layout.floors.forEach((f, i) => assert.equal(f.floorY - cabTop(layout, i), LIFT.cabH)) +}) + +test('Start: Kabine steht auf Etage 2 im Leerlauf; at() gilt bis 1,5 Einheiten Abstand und ist bei ungültigem Index false', () => { + const lift = createLift(layout) + assert.equal(lift.y, cabTop(layout, 2)); assert.equal(lift.state, 'idle') + layout.floors.forEach((f, i) => assert.equal(lift.at(i), i === 2, `at(${i})`)) + lift.y = cabTop(layout, 2) + 1.4; assert.ok(lift.at(2)) + lift.y = cabTop(layout, 2) - 1.4; assert.ok(lift.at(2)) + lift.y = cabTop(layout, 2) + 1.5; assert.ok(!lift.at(2)) + for (const i of [-1, 10, 99, 2.5, NaN, '2', undefined, null]) assert.equal(lift.at(i), false) +}) + +test('Layout mit nur zwei Etagen: Startetage ist die letzte vorhandene (kein Wurf)', () => { + const small = buildLayout([], FLOORS.slice(0, 2)), lift = createLift(small) + assert.equal(lift.y, cabTop(small, 1)); assert.ok(lift.at(1)) +}) + +test('Zustandsfolge einer Fahrt; das Schließen dauert 0,3 s', () => { + const lift = createLift(layout), a = { x: 0, y: 0, frame: 1 }, req = lift.request(a, 4, 6), seen = [lift.state] + const step = () => { lift.update(0.05); if (seen.at(-1) !== lift.state) seen.push(lift.state) } + stepUntil(step, () => req.st === 'open') + assert.equal(lift.state, 'boarding') + req.st = 'boarded' + step(); assert.equal(lift.state, 'closing') + for (let i = 0; i < 5; i++) step() + assert.equal(lift.state, 'closing') // 0,25 s Tür + step(); step(); assert.equal(lift.state, 'riding') // 0,35 s + stepUntil(step, () => req.st === 'arrived') + assert.equal(lift.state, 'unloading') + step(); assert.equal(lift.state, 'unloading') // wartet auf den Fahrgast + req.st = 'out'; step() + assert.deepEqual(seen, ['idle', 'toPick', 'boarding', 'closing', 'riding', 'unloading', 'idle']) + assert.equal(req.st, 'out') +}) + +test('Kabine fährt mit 110 Einheiten pro Sekunde, nach oben und nach unten', () => { + const lift = createLift(layout), y0 = lift.y + lift.request({}, 8, 1) + for (let i = 0; i < 20; i++) lift.update(0.05) // 1 s + assert.ok(Math.abs(lift.y - (y0 + 110)) < 1e-9, `nach 1 s ${lift.y - y0}`) + const up = createLift(layout) + up.request({}, 0, 1) + for (let i = 0; i < 4; i++) up.update(0.05) // 0,2 s + assert.ok(Math.abs(up.y - (y0 - 22)) < 1e-9, `nach 0,2 s ${up.y - y0}`) + for (let i = 0; i < 6; i++) up.update(0.05) // Etage 0 liegt 46 Einheiten höher: erreicht, kein Überschwingen + assert.equal(up.y, cabTop(layout, 0)); assert.equal(up.state, 'boarding') +}) + +test('Fahrgast bleibt bis zum Schließen unberührt, folgt dann der Kabine und wird beim Aussteigen losgelassen', () => { + const lift = createLift(layout), a = { x: 12, y: 34, frame: 1 }, req = lift.request(a, 5, 1) + until(lift, () => req.st === 'open') + assert.deepEqual(a, { x: 12, y: 34, frame: 1 }) // Anfahren und Einsteigen: die Regie bewegt ihn + a.x = LIFT.cabWx; a.y = lift.y + 7; a.frame = 0 // die Regie hat ihn in die Kabine gestellt (Schritt 0, Blick nach vorn) + req.st = 'boarded' + let followed = 0 + stepUntil(() => { + lift.update(0.05) + if (lift.state === 'closing' || lift.state === 'riding') { + if (a.y !== lift.y + 7 || a.x !== LIFT.cabWx || a.frame !== 0) assert.fail(`Fahrgast ${a.x}/${a.y}/${a.frame} bei Kabine ${lift.y}`) + followed++ + } + }, () => req.st === 'arrived') + assert.ok(followed > 10, `folgte nur ${followed}-mal`) + assert.equal(a.y, cabTop(layout, 1) + 7) + a.x = 77; a.y = 66; a.frame = 1 // Aussteigen: die Regie setzt ihn selbst + lift.update(0.05); lift.update(0.05) + assert.deepEqual(a, { x: 77, y: 66, frame: 1 }) +}) + +test('Fahrt auf derselben Etage öffnet sofort und schließt ab', () => { + const lift = createLift(layout), req = lift.request({}, 2, 2) + lift.update(0.05); assert.equal(req.st, 'open') + req.st = 'boarded'; until(lift, () => req.st === 'arrived'); assert.equal(req.st, 'arrived') + req.st = 'out'; lift.update(0.05); assert.equal(lift.state, 'idle') +}) + +test('cancel während der Fahrt: Kabine frei, Fahrgast wird nicht mehr bewegt, nächste Anfrage kommt dran', () => { + const lift = createLift(layout), a = { x: LIFT.cabWx, y: 0, frame: 0 }, r1 = lift.request(a, 2, 9), r2 = lift.request({}, 3, 4) + until(lift, () => r1.st === 'open'); r1.st = 'boarded' + until(lift, () => lift.state === 'riding'); lift.update(0.05) + lift.cancel(a) + assert.equal(r1.st, 'cancelled'); assert.equal(lift.state, 'idle') + const before = { ...a } + lift.update(0.05); assert.deepEqual(a, before) + until(lift, () => r2.st === 'open'); assert.equal(r2.st, 'open'); assert.equal(lift.state, 'boarding') + assert.deepEqual(a, before) +}) + +test('cancel einer wartenden Anfrage: nie bedient, die übrigen bleiben in Reihenfolge; mehrere Anfragen eines Agenten fallen alle', () => { + const lift = createLift(layout), a2 = {} + const r1 = lift.request({}, 1, 4), r2 = lift.request(a2, 5, 2), r3 = lift.request({}, 6, 3), r4 = lift.request(a2, 7, 3) + lift.cancel(a2) + assert.deepEqual([r1.st, r2.st, r3.st, r4.st], ['queued', 'cancelled', 'queued', 'cancelled']) + until(lift, () => r1.st === 'open'); r1.st = 'boarded'; until(lift, () => r1.st === 'arrived'); r1.st = 'out' + const seen = new Set() + until(lift, () => { seen.add(r2.st).add(r4.st); return r3.st === 'open' }) + assert.equal(r3.st, 'open') + assert.deepEqual([...seen], ['cancelled']) +}) + +test('cancel eines unbekannten oder bereits ausgestiegenen Agenten ändert nichts', () => { + const lift = createLift(layout), a = {}, r = lift.request(a, 2, 3) + until(lift, () => r.st === 'open'); r.st = 'boarded'; until(lift, () => r.st === 'arrived'); r.st = 'out' + lift.cancel({}); lift.cancel(undefined); assert.equal(r.st, 'out'); assert.equal(lift.state, 'unloading') + lift.cancel(a); assert.equal(r.st, 'out') // ausgestiegen bleibt ausgestiegen + lift.update(0.05); assert.equal(lift.state, 'idle') +}) + +test('update überspringt von außen abgebrochene Anfragen (wartend und laufend)', () => { + const lift = createLift(layout) + const r1 = lift.request({}, 1, 4), r2 = lift.request({}, 5, 2), r3 = lift.request({}, 6, 3) + r2.st = 'cancelled' + until(lift, () => r1.st === 'open') + r1.st = 'cancelled' // laufende Anfrage von außen abgebrochen: kein Festhängen im Zustand boarding + lift.update(0.05) + assert.equal(lift.state, 'toPick') + until(lift, () => r3.st === 'open'); assert.equal(r3.st, 'open') + assert.equal(r2.st, 'cancelled'); assert.equal(r1.st, 'cancelled') +}) + +test('reset bricht Wartende und die laufende Anfrage ab, ausgestiegene bleiben; danach fährt der Aufzug wieder', () => { + const lift = createLift(layout), r1 = lift.request({}, 1, 4), r2 = lift.request({}, 5, 2), r3 = lift.request({}, 6, 3) + until(lift, () => r1.st === 'open'); r1.st = 'boarded'; until(lift, () => r1.st === 'arrived'); r1.st = 'out'; lift.update(0.05) + until(lift, () => r2.st === 'open') + lift.reset() + assert.deepEqual([r1.st, r2.st, r3.st], ['out', 'cancelled', 'cancelled']); assert.equal(lift.state, 'idle') + lift.update(0.05); assert.equal(lift.state, 'idle') // Warteschlange ist leer + const r4 = lift.request({}, 3, 5); until(lift, () => r4.st === 'open'); assert.equal(r4.st, 'open') +}) + +test('ungültige Anfragen (Etage, Agent) kommen abgebrochen zurück und blockieren nichts', () => { + const lift = createLift(layout) + for (const [a, from, to] of [[{}, -1, 3], [{}, 1, 10], [{}, 1.5, 2], [{}, '1', 2], [{}, 1], [{}, NaN, 2], [null, 1, 2], [undefined, 1, 2], ['x', 1, 2]]) { + const r = lift.request(a, from, to) + assert.equal(r.st, 'cancelled', JSON.stringify([from, to])) + assert.equal(r.a, a) + } + lift.update(0.05); assert.equal(lift.state, 'idle') + const ok = lift.request({}, 1, 2); until(lift, () => ok.st === 'open'); assert.equal(ok.st, 'open') +}) + +test('unbrauchbares dt (0, negativ, NaN, Infinity, kein Zahlentyp) ändert nichts', () => { + const lift = createLift(layout), r = lift.request({}, 8, 1), y0 = lift.y + for (const dt of [0, -1, NaN, Infinity, undefined, '0.05']) lift.update(dt) + assert.equal(lift.state, 'idle'); assert.equal(lift.y, y0); assert.equal(r.st, 'queued') +}) + +// ---- Kabine zeichnen (Raster 3, kein setGrid: logische Einheit = 3 Geräte-Pixel) ---- + +const K = 3 +const rect = (x, y, w, h, fill) => ({ x: x * K, y: y * K, w: w * K, h: h * K, fill }) + +test('drawBack: Seil von oben bis zur Kabine, Rückwand mit Handlauf und Fußleiste', () => { + const lift = createLift(layout), { ctx, rects } = makeCtx(), cy = cabTop(layout, 2) + lift.drawBack(ctx) + assert.deepEqual(rects, [ + rect(28, 9, 1, cy - 9, PAL.metal2), + rect(18, cy, 20, 21, '#f1f3f8'), + rect(19, cy + 11, 18, 1, '#cfd5df'), + rect(19, cy + 18, 18, 2, '#e1e5ec'), + ]) +}) + +test('drawFront: Glasschleier, Rahmen, Lampe und Aufhängung; save/restore gleichen sich aus', () => { + const lift = createLift(layout), { ctx, rects, counts, alphas } = makeCtx(), cy = cabTop(layout, 2) + lift.drawFront(ctx) + assert.deepEqual(rects, [ + rect(19, cy + 1, 18, 19, '#ffffff'), + rect(18, cy, 20, 1, PAL.metal2), + rect(18, cy + 20, 20, 1, PAL.metal2), + rect(18, cy, 1, 21, PAL.metal2), + rect(37, cy, 1, 21, PAL.metal2), + rect(24, cy + 1, 8, 1, '#ffe9a8'), + rect(26, cy - 2, 4, 2, PAL.metal2), + ]) + assert.deepEqual(alphas, [0.18]); assert.equal(counts.save, 1); assert.equal(counts.restore, 1) +}) + +test('Kabine wird an der gerundeten Fahrposition gezeichnet und bleibt im Schacht (x 18..38)', () => { + const lift = createLift(layout) + lift.request({}, 8, 1) + lift.update(0.05); lift.update(0.05); lift.update(0.05) + assert.ok(!Number.isInteger(lift.y), `Testvoraussetzung: Kabine zwischen Rastern (y ${lift.y})`) + const cy = Math.round(lift.y), back = makeCtx(), front = makeCtx() + lift.drawBack(back.ctx); lift.drawFront(front.ctx) + assert.deepEqual(back.rects[1], rect(18, cy, 20, 21, '#f1f3f8')) + assert.deepEqual(back.rects[0], rect(28, 9, 1, cy - 9, PAL.metal2)) + assert.deepEqual(front.rects[1], rect(18, cy, 20, 1, PAL.metal2)) + for (const r of [...back.rects.slice(1), ...front.rects]) assert.ok(r.x >= 18 * K && r.x + r.w <= 38 * K, JSON.stringify(r)) +})