feat(minecraft): implement raw event pipeline
This commit is contained in:
@@ -1,70 +1,235 @@
|
||||
import type { Logg } from '@guiiai/logg'
|
||||
|
||||
import type { EventManager } from './event-manager'
|
||||
import type { RawPerceptionEvent } from './raw-events'
|
||||
|
||||
import { LeakyBucket } from './leaky-bucket'
|
||||
|
||||
export interface AttentionEventPayload {
|
||||
raw: RawPerceptionEvent
|
||||
intensity: number
|
||||
export interface PlayerAttentionEventPayload {
|
||||
kind: 'player'
|
||||
playerName?: string
|
||||
hasLineOfSight?: boolean
|
||||
distance?: number
|
||||
playerAction: 'move' | 'punch' | 'teabag' | 'sound' | 'damage' | 'pickup'
|
||||
}
|
||||
|
||||
export interface MobAttentionEventPayload {
|
||||
kind: 'mob'
|
||||
mobName?: string
|
||||
hasLineOfSight?: boolean
|
||||
distance?: number
|
||||
mobAction: string
|
||||
}
|
||||
|
||||
export type AttentionEventPayload = PlayerAttentionEventPayload | MobAttentionEventPayload
|
||||
|
||||
export class AttentionDetector {
|
||||
private readonly buckets = new Map<string, LeakyBucket>()
|
||||
|
||||
constructor(private readonly deps: { eventManager: EventManager }) { }
|
||||
private readonly movementState = new Map<
|
||||
string,
|
||||
{
|
||||
movingSince: number
|
||||
lastSeenMove: number
|
||||
emitted: boolean
|
||||
}
|
||||
>()
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
eventManager: EventManager
|
||||
logger: Logg
|
||||
},
|
||||
) { }
|
||||
|
||||
public tick(deltaMs: number): void {
|
||||
for (const bucket of this.buckets.values()) {
|
||||
bucket.tick(deltaMs)
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
for (const [id, state] of this.movementState.entries()) {
|
||||
if (now - state.lastSeenMove > 250) {
|
||||
this.movementState.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ingest(event: RawPerceptionEvent): void {
|
||||
const key = this.keyOf(event)
|
||||
const bucket = this.getBucket(key)
|
||||
const dispatch: Record<string, Record<string, (event: RawPerceptionEvent) => void>> = {
|
||||
sighted: {
|
||||
arm_swing: event => this.onPunch(event as any),
|
||||
sneak_toggle: event => this.onSneakToggle(event as any),
|
||||
entity_moved: event => this.onMove(event as any),
|
||||
},
|
||||
heard: {
|
||||
sound: event => this.onSound(event as any),
|
||||
},
|
||||
felt: {
|
||||
damage_taken: event => this.onDamage(event as any),
|
||||
item_collected: event => this.onPickup(event as any),
|
||||
},
|
||||
}
|
||||
dispatch[event.modality]?.[event.kind]?.(event)
|
||||
}
|
||||
|
||||
const weight = 1
|
||||
const { fired, value } = bucket.add(weight)
|
||||
private onPunch(event: Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'arm_swing' }>): void {
|
||||
// Heuristic: 3 swings in ~1s triggers
|
||||
const bucket = this.getBucket(`punch:${event.entityId}`, {
|
||||
capacity: 3,
|
||||
leakPerSecond: 3,
|
||||
trigger: 3,
|
||||
})
|
||||
|
||||
const { fired } = bucket.add(1)
|
||||
if (!fired)
|
||||
return
|
||||
|
||||
this.emitAttention({
|
||||
kind: 'player',
|
||||
playerName: event.displayName,
|
||||
hasLineOfSight: event.hasLineOfSight,
|
||||
distance: event.distance,
|
||||
playerAction: 'punch',
|
||||
})
|
||||
}
|
||||
|
||||
private onSneakToggle(event: Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'sneak_toggle' }>): void {
|
||||
// >= 4 toggles within 2s (leaky bucket approximation)
|
||||
const bucket = this.getBucket(`teabag:${event.entityId}`, {
|
||||
capacity: 4,
|
||||
leakPerSecond: 2,
|
||||
trigger: 4,
|
||||
})
|
||||
|
||||
const { fired } = bucket.add(1)
|
||||
if (!fired)
|
||||
return
|
||||
|
||||
this.emitAttention({
|
||||
kind: 'player',
|
||||
playerName: event.displayName,
|
||||
hasLineOfSight: event.hasLineOfSight,
|
||||
distance: event.distance,
|
||||
playerAction: 'teabag',
|
||||
})
|
||||
}
|
||||
|
||||
private onMove(event: Extract<RawPerceptionEvent, { modality: 'sighted', kind: 'entity_moved' }>): void {
|
||||
// Only count players for "attracting attention"
|
||||
if (event.entityType !== 'player')
|
||||
return
|
||||
|
||||
const now = Date.now()
|
||||
const state = this.movementState.get(event.entityId)
|
||||
if (!state) {
|
||||
this.movementState.set(event.entityId, {
|
||||
movingSince: now,
|
||||
lastSeenMove: now,
|
||||
emitted: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
state.lastSeenMove = now
|
||||
if (state.emitted)
|
||||
return
|
||||
|
||||
if (now - state.movingSince < 600)
|
||||
return
|
||||
|
||||
// Cooldown gate to avoid spamming: 1 trigger, leaks over ~3s
|
||||
const bucket = this.getBucket(`move:${event.entityId}`, {
|
||||
capacity: 1,
|
||||
leakPerSecond: 1 / 3,
|
||||
trigger: 1,
|
||||
})
|
||||
|
||||
const { fired } = bucket.add(1)
|
||||
if (!fired)
|
||||
return
|
||||
|
||||
state.emitted = true
|
||||
this.emitAttention({
|
||||
kind: 'player',
|
||||
playerName: event.displayName,
|
||||
hasLineOfSight: event.hasLineOfSight,
|
||||
distance: event.distance,
|
||||
playerAction: 'move',
|
||||
})
|
||||
}
|
||||
|
||||
private onSound(event: Extract<RawPerceptionEvent, { modality: 'heard', kind: 'sound' }>): void {
|
||||
// Any sound within range is "interesting". Gate by soundId to prevent spam.
|
||||
const bucket = this.getBucket(`sound:${event.soundId}`, {
|
||||
capacity: 1,
|
||||
leakPerSecond: 1, // ~1s cooldown per soundId
|
||||
trigger: 1,
|
||||
})
|
||||
|
||||
const { fired } = bucket.add(1)
|
||||
if (!fired)
|
||||
return
|
||||
|
||||
this.emitAttention({
|
||||
kind: 'player',
|
||||
playerAction: 'sound',
|
||||
distance: event.distance,
|
||||
})
|
||||
}
|
||||
|
||||
private onDamage(_event: Extract<RawPerceptionEvent, { modality: 'felt', kind: 'damage_taken' }>): void {
|
||||
// Self-damage is intrinsically salient; gate with small cooldown
|
||||
const bucket = this.getBucket('felt:damage', {
|
||||
capacity: 1,
|
||||
leakPerSecond: 1 / 2,
|
||||
trigger: 1,
|
||||
})
|
||||
|
||||
const { fired } = bucket.add(1)
|
||||
if (!fired)
|
||||
return
|
||||
|
||||
this.emitAttention({
|
||||
kind: 'player',
|
||||
playerAction: 'damage',
|
||||
})
|
||||
}
|
||||
|
||||
private onPickup(_event: Extract<RawPerceptionEvent, { modality: 'felt', kind: 'item_collected' }>): void {
|
||||
// Item pickup can be spammy (e.g. farms); apply a small cooldown
|
||||
const bucket = this.getBucket('felt:pickup', {
|
||||
capacity: 1,
|
||||
leakPerSecond: 1, // ~1s cooldown
|
||||
trigger: 1,
|
||||
})
|
||||
|
||||
const { fired } = bucket.add(1)
|
||||
if (!fired)
|
||||
return
|
||||
|
||||
this.emitAttention({
|
||||
kind: 'player',
|
||||
playerAction: 'pickup',
|
||||
})
|
||||
}
|
||||
|
||||
private emitAttention(payload: AttentionEventPayload): void {
|
||||
this.deps.eventManager.emit<AttentionEventPayload>({
|
||||
type: 'perception',
|
||||
payload: {
|
||||
raw: event,
|
||||
intensity: value,
|
||||
},
|
||||
payload,
|
||||
source: { type: 'minecraft', id: 'perception' },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
private getBucket(key: string): LeakyBucket {
|
||||
private getBucket(key: string, config: { capacity: number, leakPerSecond: number, trigger: number }): LeakyBucket {
|
||||
const existing = this.buckets.get(key)
|
||||
if (existing)
|
||||
return existing
|
||||
|
||||
const created = new LeakyBucket({
|
||||
capacity: 10,
|
||||
leakPerSecond: 2,
|
||||
trigger: 3,
|
||||
})
|
||||
|
||||
const created = new LeakyBucket(config)
|
||||
this.buckets.set(key, created)
|
||||
return created
|
||||
}
|
||||
|
||||
private keyOf(event: RawPerceptionEvent): string {
|
||||
switch (event.modality) {
|
||||
case 'sighted':
|
||||
return `sighted:${event.kind}:${(event as any).entityId ?? 'unknown'}`
|
||||
case 'heard':
|
||||
return `heard:${event.kind}:${event.soundId}`
|
||||
case 'felt':
|
||||
return `felt:${event.kind}`
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export class EventManager {
|
||||
// TODO: Temporal Context tracking
|
||||
// TODO: Salience Detection / Filtering noise
|
||||
|
||||
// Sort/Filter logic could go here in the future
|
||||
if (!event.priority) {
|
||||
event.priority = 0 // Default priority
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import type { EventManager } from './event-manager'
|
||||
import type { RawPerceptionEvent } from './raw-events'
|
||||
|
||||
import { AttentionDetector } from './attention-detector'
|
||||
import { MineflayerPerceptionCollector } from './mineflayer-perception-collector'
|
||||
import { RawEventBuffer } from './raw-event-buffer'
|
||||
|
||||
export class PerceptionPipeline {
|
||||
private readonly buffer = new RawEventBuffer()
|
||||
private readonly detector: AttentionDetector
|
||||
private collector: MineflayerPerceptionCollector | null = null
|
||||
private initialized = false
|
||||
|
||||
constructor(
|
||||
@@ -18,14 +20,28 @@ export class PerceptionPipeline {
|
||||
logger: Logg
|
||||
},
|
||||
) {
|
||||
this.detector = new AttentionDetector({ eventManager: this.deps.eventManager })
|
||||
this.detector = new AttentionDetector({
|
||||
eventManager: this.deps.eventManager,
|
||||
logger: this.deps.logger,
|
||||
})
|
||||
}
|
||||
|
||||
public init(_bot: MineflayerWithAgents): void {
|
||||
public init(bot: MineflayerWithAgents): void {
|
||||
this.initialized = true
|
||||
|
||||
this.collector = new MineflayerPerceptionCollector({
|
||||
logger: this.deps.logger,
|
||||
emitRaw: (event) => {
|
||||
this.collect(event)
|
||||
},
|
||||
maxDistance: 32,
|
||||
})
|
||||
this.collector.init(bot)
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.collector?.destroy()
|
||||
this.collector = null
|
||||
this.buffer.clear()
|
||||
this.initialized = false
|
||||
}
|
||||
|
||||
@@ -200,6 +200,10 @@ export class Mineflayer extends EventEmitter<EventHandlers> {
|
||||
this.ticker.on(event, cb)
|
||||
}
|
||||
|
||||
public offTick(event: TickEvents, cb: TickEventsHandler<TickEvents>) {
|
||||
this.ticker.off(event, cb)
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
for (const plugin of this.options?.plugins || []) {
|
||||
if (plugin.beforeCleanup) {
|
||||
|
||||
Reference in New Issue
Block a user