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, ev } from './helpers/workersFixtures.mjs' // 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, 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') const { PAL } = await loadModule('src/components/workers/engine/sprites.js') const { createDirector, drawAgents } = D function setup() { const layout = buildLayout(), lift = createLift(layout), fx = createFx(), look = createLook() return { layout, lift, fx, look, d: createDirector({ layout, lift, fx, look, rnd: () => 0.5 }) } } const step = (d, n, dt = 0.1) => { for (let i = 0; i < n; i++) d.update(dt) } const rectsOf = (ctx, fill) => ctx.rects.filter((r) => r.fill === fill) const draw = (d, selectedId = null, t = 1) => { const m = makeCtx(); drawAgents(m.ctx, d, selectedId, t); return m } const depth = (a) => a.y + a.lane + a.bob + 13 * a.scale test('erster Snapshot: alle 19 an ihrem Platz, Pose nach Status, „!“ bei Fehler', () => { const { d, layout } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf', rita: 'wartet', bea: 'fehler' } })) assert.equal(d.agents.length, 19) for (const a of d.agents) { const h = homeOf(layout, a.id); assert.deepEqual([a.x, a.y], [h.x, h.y], a.id) } assert.deepEqual([d.byId.kim.pose, d.byId.rita.pose, d.byId.otto.pose, d.byId.bea.alert], ['coffee', 'stand', 'type', true]) }) test('erster Snapshot stellt Agenten auf den Platz, auch wenn sie vorher woanders standen', () => { const { d, layout } = setup(); for (const a of d.agents) { a.x = 5; a.y = 7; a.carrying = true; a.frame = 1 } d.applyState(makeState()) for (const a of d.agents) { const h = homeOf(layout, a.id); assert.deepEqual([a.x, a.y, a.carrying, a.frame, a.floor], [h.x, h.y, false, 0, h.floor], a.id) } assert.deepEqual([d.byId.otto.lane, d.byId.bob.lane], [0, -2]) }) test('nicht angeschlossen oder im Zustand fehlend ⇒ grau, schläft, Raum in look.greyRooms (Review Focus 4)', () => { const { d, look } = setup(); const st = makeState({ connected: { kim: false } }); st.workers = st.workers.filter((w) => w.workerId !== 'pia') d.applyState(st) assert.deepEqual([d.byId.kim.grey, d.byId.kim.pose, d.byId.pia.grey, d.byId.pia.pose, d.byId.bob.grey], [true, 'sleep', true, 'sleep', false]) assert.deepEqual([...look.greyRooms].sort(), ['kleinanzeigen', 'portale']) }) test('Kennzahlen landen in look; neuer Pokal blinkt nur bei steigender Zahl, nicht beim ersten Snapshot', () => { const { d, look } = setup(); d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 9, botLeads: 112 } })) assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0]) d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 10, botLeads: 112 } })); assert.ok(look.trophyPop > 0) d.applyState(makeState({ paused: true })); assert.equal(look.paused, true) d.applyState(makeState({ sendingHalted: true, sendingHaltedReason: 'Zwei harte Bounces' })); assert.equal(look.paused, false) // Notbremse (H4) ist keine Pause: kein Schleier, die Meldung zeigt das Board }) test('Leerlauf: erst Kaffee, nach 25 s Schlaf; Pokal-Seiten blättern alle 6 s', () => { const { d, look } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf' }, kpis: { dealsMonth: 9 } })) const step = (n) => { for (let i = 0; i < n; i++) d.update(0.1) } step(70); assert.equal(look.trophyPage, 1) // 7 s: erste Blätterung bei 6 s step(60); assert.equal(look.trophyPage, 0); assert.equal(d.byId.kim.pose, 'coffee') // 13 s: zweite Blätterung bei 12 s step(140); assert.equal(d.byId.kim.pose, 'sleep') // 27 s > 25 s }) test('drawAgents: Trefferflächen für alle 19, jeder ist anklickbar', () => { const { d } = setup(); d.applyState(makeState({ connected: { kim: false }, statuses: { rita: 'fehler' } })); const { ctx } = makeCtx(); drawAgents(ctx, d, 'bob', 1) const boxes = d.boxes(); assert.equal(boxes.length, 19) for (const { id, box } of boxes) assert.equal(pickAgent(boxes, box.x + box.w / 2, box.y + box.h / 2), id, id) }) test('Konstanten der Regie', () => { assert.deepEqual([D.BACKLOG_KEEP, D.GROUPS_PER_AGENT, D.IDLE_COFFEE_S, D.STEP_TIMEOUT_S, D.BUBBLE_MAX], [6, 4, 25, 45, 3]) }) test('Agent hat alle Felder der Schnittstelle; Hut, Größe, Blickrichtung und Platz stimmen', () => { const { d, layout } = setup(); d.applyState(makeState()) const felder = ['id', 'name', 'hat', 'scale', 'x', 'y', 'floor', 'facing', 'frame', 'pose', 'carrying', 'stackN', 'say', 'bubT', 'grey', 'alert', 'status', 'task', 'counters', 'lane', 'bob', 'idleT', 'cheer', 'q', 'cur', 'stepT', 'groups', 'box', 'home'] for (const a of d.agents) for (const f of felder) assert.ok(Object.hasOwn(a, f), `${a.id}.${f}`) assert.deepEqual(d.agents.map((a) => a.id), [...IDS]) assert.deepEqual([d.byId.otto.hat, d.byId.otto.scale, d.byId.bob.hat, d.byId.paul.hat, d.byId.kim.hat, d.byId.kim.scale], ['overseer', 2, 'opus', 'sonnet', 'haiku', 1]) assert.deepEqual([d.byId.otto.name, d.byId.clara.name], ['Otto', 'Clara']) assert.deepEqual(['ali', 'hanna', 'sven', 'lea', 'paul', 'clara'].map((id) => d.byId[id].facing), [-1, -1, -1, -1, -1, -1]) assert.deepEqual(['otto', 'rita', 'bea', 'arne'].map((id) => d.byId[id].facing), [1, 1, 1, 1]) assert.deepEqual(d.agents.map((a) => [a.floor, a.home]), d.agents.map((a) => [homeOf(layout, a.id).floor, homeOf(layout, a.id)])) assert.deepEqual([d.byId.otto.lane, d.byId.bob.lane], [0, -2]) assert.equal(d.byId.constructor, undefined) assert.equal(d.pending(), 0) }) test('spätere Snapshots bewegen nie; Status, Pose und Aufgabe nur bei Agenten ohne laufende Gruppe', () => { const { d } = setup(); d.applyState(makeState()); const kim = d.byId.kim kim.x = 77; kim.y = 66 const st1 = makeState({ statuses: { kim: 'leerlauf' } }); st1.workers.find((w) => w.workerId === 'kim').task = 'Pause'; st1.workers.find((w) => w.workerId === 'kim').counters = { gelesen: 4 } d.applyState(st1) assert.deepEqual([kim.x, kim.y, kim.status, kim.pose, kim.task, kim.counters], [77, 66, 'leerlauf', 'coffee', 'Pause', { gelesen: 4 }]) // laufende Gruppe: der Status gilt sofort, Pose und Aufgabe kommen erst nach dem Ende der Gruppe kim.groups = 1; kim.pose = 'type'; kim.task = 'alt' const st2 = makeState({ statuses: { kim: 'fehler' } }); st2.workers.find((w) => w.workerId === 'kim').task = 'neu' d.applyState(st2) assert.deepEqual([kim.status, kim.alert, kim.pose, kim.task], ['fehler', true, 'type', 'alt']) d.update(0.05); assert.equal(kim.pose, 'type') kim.groups = 0; d.update(0.05); assert.equal(kim.pose, 'stand') d.applyState(st2); assert.equal(kim.task, 'neu') }) test('unbrauchbarer Zustand wirft nicht: alle grau und schlafend, keine Pause', () => { for (const bad of [null, undefined, {}, 'x', { workers: 'x' }, { workers: [null, 5, { workerId: 7 }] }]) { const { d, look } = setup(); d.applyState(makeState({ paused: true })); d.applyState(bad) assert.deepEqual([d.agents.every((a) => a.grey && a.pose === 'sleep' && a.status === 'grau'), look.paused, look.greyRooms.size], [true, false, 19], String(bad)) } }) test('busyRooms: Räume der arbeitenden Worker; graue und ruhende gehören nicht dazu', () => { const { d, look } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf', bea: 'wartet', paul: 'fehler' }, connected: { pia: false } })) assert.equal(look.busyRooms.has('bruecke'), true) for (const room of ['kleinanzeigen', 'portale', 'waage', 'pruefung']) assert.equal(look.busyRooms.has(room), false, room) assert.equal(look.busyRooms.size, 19 - 4) }) test('Kennzahlen: fehlende Werte lassen look stehen und lösen kein Blinken aus; Rückgang blinkt nicht', () => { const { d, look } = setup(); d.applyState(makeState({ kpis: { pultOpen: 4, dealsMonth: 9, botLeads: 112 } })) const bare = makeState(); delete bare.kpis; d.applyState(bare) assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0]) d.applyState(makeState({ kpis: { dealsMonth: 'x', pultOpen: NaN, botLeads: null } })); assert.deepEqual([look.pultOpen, look.dealsMonth, look.botLeads, look.trophyPop], [4, 9, 112, 0]) d.applyState(makeState({ kpis: { dealsMonth: 9 } })); assert.equal(look.trophyPop, 0) d.applyState(makeState({ kpis: { dealsMonth: 9.8, pultOpen: -3, botLeads: 112.9 } })); assert.deepEqual([look.dealsMonth, look.pultOpen, look.botLeads, look.trophyPop], [9, 0, 112, 0]) // ganze Zahlen, nie negativ d.applyState(makeState({ kpis: { dealsMonth: 2 } })); assert.deepEqual([look.dealsMonth, look.trophyPop], [2, 0]) d.applyState(makeState({ kpis: { dealsMonth: 3 } })); assert.equal(look.trophyPop, 1.2) // ein Zustand ohne Kennzahlen beim ersten Snapshot zählt nicht als erster Wert const b = setup(); b.d.applyState(bare); b.d.applyState(makeState({ kpis: { dealsMonth: 9 } })); assert.equal(b.look.trophyPop, 0) }) test('update: Pokal-Blinken und Tresor-Blitz zählen herunter; Pause friert alles ein', () => { const { d, look, fx } = setup(); d.applyState(makeState({ kpis: { dealsMonth: 1 } })); d.applyState(makeState({ kpis: { dealsMonth: 2 } })); look.vaultFlash = 0.7 let ended = 0; fx.add(0.4, () => {}, () => { ended += 1 }) step(d, 5); assert.ok(look.trophyPop < 1.2 && look.trophyPop > 0.6); assert.ok(look.vaultFlash < 0.7 && look.vaultFlash > 0.1); assert.equal(ended, 1) step(d, 20); assert.deepEqual([look.trophyPop, look.vaultFlash], [0, 0]) const kim = d.byId.kim; d.applyState(makeState({ statuses: { kim: 'leerlauf' }, paused: true })); look.pageT = 0 const before = [kim.idleT, look.pageT]; step(d, 100); assert.deepEqual([kim.idleT, look.pageT], before) d.applyState(makeState({ statuses: { kim: 'leerlauf' }, paused: false })); step(d, 10); assert.ok(kim.idleT > 0.9) }) test('update: ungültiges dt wird ignoriert; der Schlaf kehrt mit dem Status zurück', () => { const { d } = setup(); d.applyState(makeState({ statuses: { kim: 'leerlauf' } })) for (const dt of [NaN, undefined, 0, -1, Infinity]) d.update(dt) assert.equal(d.byId.kim.idleT, 0) step(d, 260); assert.equal(d.byId.kim.pose, 'sleep') d.applyState(makeState({ statuses: { kim: 'arbeitet' } })); d.update(0.1); assert.deepEqual([d.byId.kim.idleT, d.byId.kim.pose], [0, 'type']) d.applyState(makeState({ statuses: { kim: 'leerlauf' } })); step(d, 10); assert.equal(d.byId.kim.pose, 'coffee') }) test('Wippen: nur wer am Platz tippt, wippt um einen Pixel; Jubel hüpft um zwei; unterwegs gilt die Spur 0', () => { const { d } = setup(); d.applyState(makeState()); const seen = new Set() for (let i = 0; i < 40; i++) { d.update(0.1); seen.add(d.byId.bob.bob) } assert.deepEqual([...seen].sort(), [-1, 0]) assert.equal(d.byId.arne.pose, 'type') d.applyState(makeState({ statuses: { bob: 'wartet' } })); d.update(0.1); assert.equal(d.byId.bob.bob, 0) d.byId.otto.cheer = 0.5; const jubel = new Set(); for (let i = 0; i < 4; i++) { d.update(0.1); jubel.add(d.byId.otto.bob) } assert.ok(jubel.has(-2) && jubel.has(0)); step(d, 3); assert.equal(d.byId.otto.cheer, 0) // Spur: am Platz -2 (außer Otto), unterwegs gleitet sie auf 0 const kim = d.byId.kim; assert.equal(kim.lane, -2); kim.x += 30; kim.frame = 1; const unterwegs = new Set(); for (let i = 0; i < 20; i++) { d.update(0.1); unterwegs.add(kim.bob) } assert.deepEqual([kim.lane, [...unterwegs]], [0, [0]]); kim.x -= 30; kim.frame = 0; d.update(0.05); assert.ok(kim.lane < 0 && kim.lane > -1) step(d, 3); assert.equal(kim.lane, -2) }) test('bubble: auf 20 Zeichen gekürzt, nach 2,2 s weg, höchstens drei fremde gleichzeitig, leer und unbekannt ignoriert', () => { const { d } = setup(); d.applyState(makeState()) assert.equal(d.bubble(d.byId.kim, 'Dies ist ein sehr langer Satz'), true); assert.equal(d.byId.kim.say, 'Dies ist ein sehr la') assert.equal(d.bubble(d.byId.rita, 'Eins'), true); assert.equal(d.bubble(d.byId.pia, 'Zwei'), true) assert.equal(d.bubble(d.byId.finn, 'Drei'), false); assert.equal(d.byId.finn.say, null) assert.equal(d.bubble(d.byId.kim, 'Neu'), true); assert.equal(d.byId.kim.say, 'Neu') // der Sprecher selbst zählt nicht mit assert.deepEqual([d.bubble(d.byId.kim, ''), d.bubble(d.byId.kim, 42), d.bubble(null, 'x')], [false, false, false]) step(d, 20); assert.equal(d.byId.kim.say, 'Neu'); step(d, 5); assert.deepEqual(d.agents.filter((a) => a.say), []) assert.equal(d.bubble(d.byId.finn, 'Jetzt'), true) d.bubble(d.byId.ali, 'Lang', 5); step(d, 30); assert.equal(d.byId.ali.say, 'Lang'); step(d, 25); assert.equal(d.byId.ali.say, null) }) test('drawAgents: y-sortiert, jede Figur genau einmal, Trefferfläche aus der Zeichnung', () => { const { d } = setup(); d.applyState(makeState()); const m = draw(d) const tiefen = d.boxes().map(({ id }) => depth(d.byId[id])) assert.deepEqual(tiefen, [...tiefen].sort((a, b) => a - b)) assert.equal(new Set(d.boxes().map((b) => b.id)).size, 19) assert.ok(m.rects.length > 19 * 40) const otto = d.byId.otto; assert.deepEqual([otto.box.w, otto.box.h], [24, 32]) const e = setup(); assert.deepEqual(e.d.boxes(), []) // vor dem ersten Zeichnen gibt es keine Flächen }) test('drawAgents: gezeichnet wird von hinten nach vorn, nicht in Listenreihenfolge', () => { const kleine = (m) => m.rects.map((r, i) => (r.w === 3 && r.h === 3 ? i : -1)).filter((i) => i >= 0) // Zellen der normalen Figuren (Raster 3) const grosse = (m) => m.rects.map((r, i) => (r.w === 6 && r.h === 6 ? i : -1)).filter((i) => i >= 0) // Zellen von Otto (Maßstab 2) const { d } = setup(); d.applyState(makeState()) const oben = draw(d); assert.ok(Math.max(...grosse(oben)) < Math.max(...kleine(oben)), 'Otto steht oben und wird zuerst gezeichnet') d.byId.otto.y = 300 const unten = draw(d); assert.ok(Math.min(...grosse(unten)) > Math.max(...kleine(unten)), 'Otto steht jetzt vor allen anderen') assert.equal(d.boxes().at(-1).id, 'otto') }) test('drawAgents: „!“ nur bei Fehler, Auswahlpfeil nur beim gewählten, Stapel nur bei mehreren Papieren', () => { const { d } = setup(); d.applyState(makeState()) assert.equal(rectsOf(draw(d), PAL.red).length, 0) assert.equal(rectsOf(draw(d), PAL.ink).length, 0) d.applyState(makeState({ statuses: { bea: 'fehler' } })); assert.ok(rectsOf(draw(d), PAL.red).length > 0) const rot = rectsOf(draw(d), PAL.red), bea = d.byId.bea assert.equal(Math.max(...rot.map((r) => r.y + r.h)) + 3, (bea.y + bea.lane - 2) * 3, 'Unterkante des „!“ (mit Umriss) steht auf der Kopfoberkante') assert.equal(rectsOf(draw(d, 'bob'), PAL.ink).length, 9) const papier = (n, carrying = true) => { d.byId.kim.carrying = carrying; d.byId.kim.stackN = n; return draw(d).rects.filter((r) => r.fill === PAL.g).length } const eins = papier(1); assert.ok(papier(2) > eins); assert.ok(papier(5) > papier(2)); assert.equal(papier(5, false), papier(1, false)) }) test('drawAgents: graue Figuren halbdurchsichtig, die Zeichnung lässt save/restore im Gleichgewicht', () => { const { d } = setup(); d.applyState(makeState({ connected: { kim: false, pia: false } })); const m = draw(d) assert.equal(m.alphas.filter((a) => a === 0.55).length, 2); assert.equal(m.counts.save, m.counts.restore) }) test('drawAgents: Blasen zuletzt; Otto seitlich; außerhalb des Bildes keine Blase', () => { const { d } = setup(); d.applyState(makeState()); d.bubble(d.byId.kim, 'Hallo'); d.bubble(d.byId.otto, 'Deal! Super!') const m = draw(d); assert.deepEqual(m.texts.map((t) => t.text).sort(), ['Deal! Super!', 'Hallo']) const weiss = m.rects.map((r, i) => [r, i]).filter(([r]) => r.fill === PAL.w && r.w > 20) assert.equal(weiss.length, 2); for (const [, i] of weiss) assert.ok(m.rects.length - i <= 4 + 4, 'Blase liegt am Ende der Zeichnung') // Otto: die Blase steht neben ihm, nicht über dem Kopf (Spitze liegt links von seiner linken Kante oder rechts von der rechten) const otto = d.byId.otto, os = m.texts.find((t) => t.text === 'Deal! Super!'), kimText = m.texts.find((t) => t.text === 'Hallo') assert.ok(os.x < otto.x * 3 - 20 || os.x > (otto.x + 24) * 3); assert.ok(os.y > otto.y * 3) assert.ok(kimText.y < d.byId.kim.y * 3) const kim = d.byId.kim, spitze = m.rects.at(-1); assert.equal(spitze.y + spitze.h, (kim.y + kim.lane - 2) * 3 + 1, 'Spitze steht auf der Kopfoberkante (Hut)') d.byId.kim.x = -10; d.byId.rita.x = 330; d.bubble(d.byId.rita, 'Rechts'); const aus = draw(d); assert.deepEqual(aus.texts.map((t) => t.text).sort(), ['Deal! Super!']) }) test('drawAgents: die Blase weicht dem „!“ und dem Auswahlpfeil aus', () => { const { d } = setup(); d.applyState(makeState({ statuses: { kim: 'fehler' } })); d.bubble(d.byId.kim, 'Kim'); d.bubble(d.byId.rita, 'Rita') const textY = (m, text) => m.texts.find((t) => t.text === text).y const m = draw(d) assert.equal(d.byId.kim.y, d.byId.rita.y) 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 })