Workers P2: Ereignis-Logik (Dedup, reduceEvents, eventToSteps)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
79be67706c
commit
3386e91916
2 files changed
+530
No files matched your search
@@ -0,0 +1,289 @@
|
||||
// Ereignis-Logik der Hustler-Seite: Deduplizierung, Zustand aus Ereignissen, Animationsschritte (Übersicht §5 und §7).
|
||||
// Kein React, kein Appwrite, kein window/document: unter Node testbar (loadModule).
|
||||
import { ROLES, roleOf } from './workersRoster.js'
|
||||
|
||||
// Ereignisarten in der Reihenfolge der Übersicht §5 (geschlossene Liste, 26 Arten)
|
||||
export const EVENT_KINDS = Object.freeze([
|
||||
'auftrag', 'leerlauf', 'gelesen', 'fund', 'doppelt', 'uebergabe', 'bewertet', 'top3', 'botlead', 'entwurf',
|
||||
'pult', 'gesendet', 'eingang', 'sortiert', 'bau_start', 'bau_fertig', 'pruefung_zurueck', 'pruefung_ok',
|
||||
'vorschau_link', 'angebot', 'deal', 'gesperrt', 'budget_warnung', 'budget_stopp', 'pause', 'fehler',
|
||||
])
|
||||
|
||||
// Schrittarten der Regie und Plätze ohne Worker, an denen Effekte beginnen oder enden
|
||||
export const STEP_OPS = Object.freeze(['task', 'say', 'pose', 'give', 'fx', 'cheer'])
|
||||
export const SPOTS = Object.freeze(['pult', 'tresor', 'aussen', 'archiv'])
|
||||
|
||||
// Höchstens so viele Ereignis-Schlüssel merkt sich takeNewEvents (älteste fallen zuerst heraus)
|
||||
const SEEN_MAX = 1000
|
||||
// Sprechblasen zeigen höchstens so viele Zeichen
|
||||
const BUBBLE_MAX = 20
|
||||
|
||||
// Arten, nach denen der Zustand (Zähler, Pult-Badge, Budget) frisch geholt werden muss
|
||||
const REFRESH_KINDS = new Set(['pult', 'gesendet', 'deal', 'botlead', 'gesperrt', 'budget_warnung', 'budget_stopp', 'pause', 'top3', 'angebot'])
|
||||
|
||||
// Beschriftung des Stempels nach meta.classification
|
||||
const CLASSIFICATION_LABEL = Object.freeze({
|
||||
ja: 'Ja!',
|
||||
frage: 'Frage',
|
||||
nein: 'Nein',
|
||||
abmelden: 'Abmeldung',
|
||||
abwesend: 'Abwesend',
|
||||
unzustellbar: 'Unzustellbar',
|
||||
beschwerde: 'Beschwerde!',
|
||||
unklar: 'Unklar',
|
||||
})
|
||||
|
||||
const WORKER_IDS = new Set(ROLES.map((r) => r.workerId))
|
||||
|
||||
function isWorker(value) {
|
||||
return typeof value === 'string' && WORKER_IDS.has(value)
|
||||
}
|
||||
|
||||
function isPlace(value) {
|
||||
return isWorker(value) || (typeof value === 'string' && SPOTS.includes(value))
|
||||
}
|
||||
|
||||
function isNum(n) {
|
||||
return typeof n === 'number' && Number.isFinite(n)
|
||||
}
|
||||
|
||||
// Zeitstempel als Millisekunden; kein Text oder nicht lesbar -> NaN
|
||||
function msOf(ts) {
|
||||
return typeof ts === 'string' ? Date.parse(ts) : NaN
|
||||
}
|
||||
|
||||
function isRecord(v) {
|
||||
return v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
}
|
||||
|
||||
/** Sprechblasen-Text auf höchstens 20 Zeichen kürzen; alles außer Text ergibt ''. */
|
||||
export function cutBubble(text) {
|
||||
return typeof text === 'string' ? text.slice(0, BUBBLE_MAX) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Ereignisse, deren Schlüssel noch nicht in `seen` steht, aufsteigend nach ts (stabil).
|
||||
* Schlüssel = id, ohne id `ts|workerId|kind|text`. Ohne gültiges ts wird das Ereignis verworfen.
|
||||
* `seen` wird ergänzt und auf die jüngsten 1000 Schlüssel begrenzt (Review Focus 3).
|
||||
*/
|
||||
export function takeNewEvents(seen, events) {
|
||||
if (!Array.isArray(events)) return []
|
||||
const valid = []
|
||||
for (const e of events) {
|
||||
if (!isRecord(e)) continue
|
||||
const ms = msOf(e.ts)
|
||||
if (Number.isNaN(ms)) continue
|
||||
valid.push({ e, ms })
|
||||
}
|
||||
valid.sort((a, b) => a.ms - b.ms)
|
||||
const fresh = []
|
||||
for (const { e } of valid) {
|
||||
const key = typeof e.id === 'string' && e.id !== '' ? e.id : `${e.ts}|${e.workerId}|${e.kind}|${e.text}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
fresh.push(e)
|
||||
}
|
||||
// Set behält die Einfügereihenfolge: die ältesten Schlüssel zuerst entfernen
|
||||
for (const key of seen) {
|
||||
if (seen.size <= SEEN_MAX) break
|
||||
seen.delete(key)
|
||||
}
|
||||
return fresh
|
||||
}
|
||||
|
||||
/** Größter gültiger Zeitstempel aus `events` und `prev` (als ursprünglicher Text); sonst `prev`. */
|
||||
export function latestTs(events, prev = null) {
|
||||
let best = prev
|
||||
let bestMs = msOf(prev)
|
||||
if (Array.isArray(events)) {
|
||||
for (const e of events) {
|
||||
const ms = isRecord(e) ? msOf(e.ts) : NaN
|
||||
if (Number.isNaN(ms)) continue
|
||||
if (Number.isNaN(bestMs) || ms > bestMs) {
|
||||
best = e.ts
|
||||
bestMs = ms
|
||||
}
|
||||
}
|
||||
}
|
||||
return best ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Zustand der Dienst-API (`state.workers`) -> Tabelle nach workerId. Ein Worker mit connected false ist grau;
|
||||
* lastTs ist der Stand des Schnappschusses (`lastEventTs`): ältere Ereignisse wirken nicht mehr.
|
||||
*/
|
||||
export function indexWorkers(state) {
|
||||
const list = isRecord(state) && Array.isArray(state.workers) ? state.workers : []
|
||||
const lastTs = isRecord(state) && typeof state.lastEventTs === 'string' ? state.lastEventTs : null
|
||||
const entries = []
|
||||
for (const w of list) {
|
||||
if (!isRecord(w) || typeof w.workerId !== 'string' || w.workerId === '') continue
|
||||
const connected = w.connected !== false
|
||||
const status = !connected ? 'grau' : typeof w.status === 'string' ? w.status : 'leerlauf'
|
||||
entries.push([w.workerId, {
|
||||
status,
|
||||
task: typeof w.task === 'string' ? w.task : '',
|
||||
lastTs,
|
||||
connected,
|
||||
counters: isRecord(w.counters) ? w.counters : {},
|
||||
}])
|
||||
}
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
function statusOf(kind) {
|
||||
if (kind === 'leerlauf') return 'leerlauf'
|
||||
if (kind === 'fehler') return 'fehler'
|
||||
return 'arbeitet'
|
||||
}
|
||||
|
||||
// Ein Ereignis wirkt nur auf einen Worker, der nicht grau ist und nicht schon etwas Neueres erlebt hat
|
||||
function takesEffect(entry, ms) {
|
||||
if (!isRecord(entry) || entry.status === 'grau') return false
|
||||
const last = msOf(entry.lastTs)
|
||||
return Number.isNaN(last) || ms >= last
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuer Zustand nach `events` (Eingabe bleibt unverändert). Nur Worker, die im Zustand stehen und im Roster
|
||||
* vorkommen, ändern sich; grau bleibt grau. Der Empfänger `to` von auftrag/uebergabe wartet danach mit dem Text.
|
||||
*/
|
||||
export function reduceEvents(stateByWorker, events) {
|
||||
const entries = isRecord(stateByWorker) ? Object.entries(stateByWorker).filter(([, entry]) => isRecord(entry)) : []
|
||||
const out = Object.fromEntries(entries.map(([id, entry]) => [id, { ...entry }]))
|
||||
const live = (id) => isWorker(id) && Object.hasOwn(out, id)
|
||||
if (!Array.isArray(events)) return out
|
||||
for (const e of events) {
|
||||
if (!isRecord(e)) continue
|
||||
const ms = msOf(e.ts)
|
||||
if (Number.isNaN(ms)) continue
|
||||
const text = typeof e.text === 'string' ? e.text : ''
|
||||
if (live(e.workerId) && takesEffect(out[e.workerId], ms)) {
|
||||
out[e.workerId] = { ...out[e.workerId], status: statusOf(e.kind), task: text, lastTs: e.ts }
|
||||
}
|
||||
if ((e.kind === 'auftrag' || e.kind === 'uebergabe') && e.to !== e.workerId && live(e.to) && takesEffect(out[e.to], ms)) {
|
||||
out[e.to] = { ...out[e.to], status: 'wartet', task: text, lastTs: e.ts }
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// meta darf Objekt, JSON-Text oder null sein (P1 liefert null, wenn leer)
|
||||
function metaOf(meta) {
|
||||
if (isRecord(meta)) return meta
|
||||
if (typeof meta === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(meta)
|
||||
if (isRecord(parsed)) return parsed
|
||||
} catch {
|
||||
// kein JSON: wie leer behandeln
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
const task = (who, text) => ({ op: 'task', who, text })
|
||||
const say = (who, text) => ({ op: 'say', who, text: cutBubble(text) })
|
||||
const pose = (who, p) => ({ op: 'pose', who, pose: p })
|
||||
const give = (from, to) => ({ op: 'give', from, to })
|
||||
const fx = (name, at, to) => (to === undefined ? { op: 'fx', fx: name, at } : { op: 'fx', fx: name, at, to })
|
||||
|
||||
// Schritte der Tabelle (Übersicht §7) für ein Ereignis, noch ungeprüft gegen Roster und Plätze
|
||||
function rawSteps(e) {
|
||||
const W = e.workerId
|
||||
const T = typeof e.text === 'string' ? e.text : ''
|
||||
const meta = metaOf(e.meta)
|
||||
switch (e.kind) {
|
||||
case 'auftrag': {
|
||||
const steps = [task(W, T)]
|
||||
if (isWorker(e.to)) steps.push(say(W, `${roleOf(e.to).name}, los!`), say(e.to, 'Verstanden'))
|
||||
return steps
|
||||
}
|
||||
case 'leerlauf':
|
||||
return [task(W, T), pose(W, 'coffee')]
|
||||
case 'gelesen':
|
||||
return [pose(W, 'type'), task(W, T), say(W, isNum(meta.count) ? `${meta.count} gelesen` : 'Gelesen')]
|
||||
case 'fund':
|
||||
return [pose(W, 'type'), task(W, T), say(W, 'Neuer Fund!'), fx('paper', W)]
|
||||
case 'doppelt':
|
||||
return [task(W, T), say(W, 'Schon bekannt'), fx('stampNo', W)]
|
||||
case 'uebergabe':
|
||||
return [task(W, T), give(W, e.to)]
|
||||
case 'bewertet':
|
||||
return [pose(W, 'type'), task(W, T), say(W, isNum(meta.score) ? `${meta.score} Punkte` : 'Bewertet')]
|
||||
case 'top3':
|
||||
return [task(W, T), say(W, 'Top 3!'), fx('stampOk', W)]
|
||||
case 'botlead':
|
||||
return [task(W, T), say(W, 'Abgelegt'), fx('drawer', W)]
|
||||
case 'entwurf':
|
||||
return [pose(W, 'type'), task(W, T), say(W, 'Entwurf fertig')]
|
||||
case 'pult':
|
||||
return [task(W, T), say(W, 'Zur Freigabe'), fx('envelope', W, 'pult')]
|
||||
case 'gesendet':
|
||||
// Max sendet selbst; bei `system` hat ein Mensch am Pult gesendet
|
||||
return isWorker(W) ? [task(W, T), say(W, 'Raus damit!'), fx('envelope', W, 'aussen')] : [fx('envelope', 'pult', 'aussen')]
|
||||
case 'eingang':
|
||||
return [task(W, T), fx('envelope', 'aussen', W), say(W, 'Antwort!')]
|
||||
case 'sortiert': {
|
||||
const c = meta.classification
|
||||
const label = typeof c === 'string' && Object.hasOwn(CLASSIFICATION_LABEL, c) ? CLASSIFICATION_LABEL[c] : 'Sortiert'
|
||||
return [task(W, T), fx('stampOk', W), say(W, label)]
|
||||
}
|
||||
case 'bau_start':
|
||||
return [pose(W, 'type'), task(W, T), say(W, 'Los geht’s')]
|
||||
case 'bau_fertig':
|
||||
return [task(W, T), say(W, 'Fertig!'), give(W, 'paul')]
|
||||
case 'pruefung_zurueck':
|
||||
return [task(W, T), fx('stampNo', W), say(W, isNum(meta.round) ? `Runde ${meta.round} zurück` : 'Zurück'), give(W, e.to)]
|
||||
case 'pruefung_ok':
|
||||
return [task(W, T), fx('stampOk', W), say(W, 'Passt!')]
|
||||
case 'vorschau_link':
|
||||
return [task(W, T), say(W, 'Link raus'), fx('envelope', W, 'aussen')]
|
||||
case 'angebot':
|
||||
return [task(W, T), say(W, 'Kauf-Link!'), fx('paper', W)]
|
||||
case 'deal':
|
||||
return [
|
||||
...[0, 0.4, 0.8].map((delay) => ({ op: 'fx', fx: 'coin', at: 'aussen', to: 'tresor', delay })),
|
||||
{ op: 'cheer', who: 'otto' },
|
||||
say('otto', 'Deal! Super!'),
|
||||
]
|
||||
case 'gesperrt': {
|
||||
// Bei `system` stempelt Arne
|
||||
const stamper = isWorker(W) ? W : 'arne'
|
||||
return [task(W, T), fx('stampNo', stamper), say(stamper, 'Gesperrt')]
|
||||
}
|
||||
case 'budget_warnung':
|
||||
return [task(W, T), say('otto', 'Budget 80 %'), fx('alert', 'otto')]
|
||||
case 'budget_stopp':
|
||||
return [task(W, T), say('otto', 'Budget leer'), fx('alert', 'otto')]
|
||||
case 'pause':
|
||||
return []
|
||||
case 'fehler':
|
||||
return [task(W, T), fx('alert', W), say(W, 'Fehler')]
|
||||
default:
|
||||
return [task(W, T)]
|
||||
}
|
||||
}
|
||||
|
||||
// Zeigt der Schritt nur auf bekannte Worker und Plätze? (Review Focus 4)
|
||||
function stepIsValid(s) {
|
||||
switch (s.op) {
|
||||
case 'give':
|
||||
return isWorker(s.from) && isWorker(s.to)
|
||||
case 'fx':
|
||||
return isPlace(s.at) && (s.to === undefined || isPlace(s.to))
|
||||
default:
|
||||
return isWorker(s.who)
|
||||
}
|
||||
}
|
||||
|
||||
/** Animationsschritte eines Ereignisses; Schritte zu unbekannten Workern oder Plätzen entfallen. */
|
||||
export function eventToSteps(event) {
|
||||
if (!isRecord(event)) return []
|
||||
return rawSteps(event).filter(stepIsValid)
|
||||
}
|
||||
|
||||
/** Muss nach diesen Ereignissen der Zustand frisch geholt werden? */
|
||||
export function needsStateRefresh(events) {
|
||||
return Array.isArray(events) && events.some((e) => isRecord(e) && REFRESH_KINDS.has(e.kind))
|
||||
}
|
||||
Reference in new issue
Block a user