feat(minecraft): add social reflex, follow player on chat
fixup chat signal not propagated
This commit is contained in:
@@ -104,6 +104,27 @@ export function CognitiveEngine(options: CognitiveEngineOptions): MineflayerPlug
|
||||
if (chatHandler.isBotMessage(username))
|
||||
return
|
||||
|
||||
// Bridge chat directly into EventBus as a signal so Reflex can react to it.
|
||||
// (PerceptionPipeline will also ingest this for Brain via EventManager.)
|
||||
eventBus.emit({
|
||||
type: 'signal:chat_message',
|
||||
payload: Object.freeze({
|
||||
type: 'chat_message',
|
||||
description: `Chat from ${username}: "${message}"`,
|
||||
sourceId: username,
|
||||
confidence: 1.0,
|
||||
timestamp: Date.now(),
|
||||
metadata: {
|
||||
username,
|
||||
message,
|
||||
},
|
||||
}),
|
||||
source: {
|
||||
component: 'perception',
|
||||
id: 'chat',
|
||||
},
|
||||
})
|
||||
|
||||
perceptionPipeline.ingest(createPerceptionFrameFromChat(username, message))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReflexBehavior } from '../types/behavior'
|
||||
export const lookAtBehavior: ReflexBehavior = {
|
||||
id: 'look-at',
|
||||
modes: ['idle', 'social'],
|
||||
cooldownMs: 1000,
|
||||
cooldownMs: 100,
|
||||
|
||||
when: (ctx) => {
|
||||
// Check if we have a recent attention signal
|
||||
|
||||
@@ -27,13 +27,14 @@ function makeBot() {
|
||||
isRaining: false,
|
||||
players: {},
|
||||
},
|
||||
interrupt: vi.fn(),
|
||||
}
|
||||
|
||||
return bot as any
|
||||
}
|
||||
|
||||
describe('reflexManager', () => {
|
||||
it('handles greeting via reflex and marks stimulus event handled', () => {
|
||||
it('handles signals without crashing', () => {
|
||||
// Mock EventBus
|
||||
const eventBus = {
|
||||
subscribe: vi.fn(),
|
||||
@@ -67,7 +68,12 @@ describe('reflexManager', () => {
|
||||
const handler = eventBus.subscribe.mock.calls[0][1]
|
||||
const signalEvent = {
|
||||
type: 'signal:social',
|
||||
payload: { type: 'social', description: 'hello' },
|
||||
payload: {
|
||||
type: 'social_gesture',
|
||||
description: 'someone teabagged',
|
||||
timestamp: Date.now(),
|
||||
metadata: { gesture: 'teabag' },
|
||||
},
|
||||
source: { component: 'ruleEngine', id: 'test' },
|
||||
timestamp: Date.now(),
|
||||
// ... other traced event props ...
|
||||
@@ -81,4 +87,120 @@ describe('reflexManager', () => {
|
||||
|
||||
reflex.destroy()
|
||||
})
|
||||
|
||||
it('updates social context from chat_message and enters social mode', () => {
|
||||
const eventBus = {
|
||||
subscribe: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
emitChild: vi.fn(),
|
||||
} as any
|
||||
|
||||
const taskExecutor = {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
} as any
|
||||
|
||||
const logger = makeLogger()
|
||||
const perception = {
|
||||
getPlayers: vi.fn(() => []),
|
||||
getEntity: vi.fn(() => null),
|
||||
entitiesWithBelief: vi.fn(() => []),
|
||||
updateEntity: vi.fn(),
|
||||
updateSelfPosition: vi.fn(),
|
||||
} as any
|
||||
|
||||
const reflex = new ReflexManager({ eventBus, perception, taskExecutor, logger })
|
||||
|
||||
const bot = makeBot()
|
||||
bot.bot.players = {
|
||||
alice: { entity: { position: { x: 1, y: 0, z: 1 }, username: 'alice' } },
|
||||
}
|
||||
reflex.init(bot)
|
||||
|
||||
const handler = eventBus.subscribe.mock.calls[0][1]
|
||||
handler({
|
||||
type: 'signal:chat_message',
|
||||
payload: {
|
||||
type: 'chat_message',
|
||||
description: 'Chat from alice: "hi"',
|
||||
sourceId: 'alice',
|
||||
timestamp: Date.now(),
|
||||
metadata: { username: 'alice', message: 'hi' },
|
||||
},
|
||||
source: { component: 'ruleEngine', id: 'test' },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
const snap = reflex.getContextSnapshot()
|
||||
expect(snap.social.lastSpeaker).toBe('alice')
|
||||
expect(snap.social.lastMessage).toBe('hi')
|
||||
expect(reflex.getMode()).toBe('social')
|
||||
|
||||
reflex.destroy()
|
||||
})
|
||||
|
||||
it('leaving social interrupts follow cleanup', () => {
|
||||
const eventBus = {
|
||||
subscribe: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
emitChild: vi.fn(),
|
||||
} as any
|
||||
|
||||
const taskExecutor = {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
} as any
|
||||
|
||||
const logger = makeLogger()
|
||||
const perception = {
|
||||
getPlayers: vi.fn(() => []),
|
||||
getEntity: vi.fn(() => null),
|
||||
entitiesWithBelief: vi.fn(() => []),
|
||||
updateEntity: vi.fn(),
|
||||
updateSelfPosition: vi.fn(),
|
||||
} as any
|
||||
|
||||
const reflex = new ReflexManager({ eventBus, perception, taskExecutor, logger })
|
||||
|
||||
const bot = makeBot()
|
||||
bot.bot.players = {
|
||||
alice: { entity: { position: { x: 1, y: 0, z: 1 }, username: 'alice' } },
|
||||
}
|
||||
reflex.init(bot)
|
||||
|
||||
const handler = eventBus.subscribe.mock.calls[0][1]
|
||||
handler({
|
||||
type: 'signal:chat_message',
|
||||
payload: {
|
||||
type: 'chat_message',
|
||||
description: 'Chat from alice: "hi"',
|
||||
sourceId: 'alice',
|
||||
timestamp: Date.now(),
|
||||
metadata: { username: 'alice', message: 'hi' },
|
||||
},
|
||||
source: { component: 'ruleEngine', id: 'test' },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
expect(reflex.getMode()).toBe('social')
|
||||
|
||||
// Force social timeout by moving time forward and triggering another signal.
|
||||
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 20_000)
|
||||
handler({
|
||||
type: 'signal:social_gesture',
|
||||
payload: {
|
||||
type: 'social_gesture',
|
||||
description: 'noop',
|
||||
timestamp: Date.now(),
|
||||
metadata: { gesture: 'wave' },
|
||||
},
|
||||
source: { component: 'ruleEngine', id: 'test' },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
expect(reflex.getMode()).toBe('idle')
|
||||
expect(bot.interrupt).toHaveBeenCalledWith('reflex:social_exit')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,14 +48,14 @@ export class ReflexManager {
|
||||
|
||||
const onStarted = () => {
|
||||
if (this.inFlightActionsCount === 0)
|
||||
this.runtime.setMode('work')
|
||||
this.runtime.transitionMode('work', this.bot)
|
||||
this.inFlightActionsCount++
|
||||
}
|
||||
|
||||
const onEnded = () => {
|
||||
this.inFlightActionsCount = Math.max(0, this.inFlightActionsCount - 1)
|
||||
if (this.inFlightActionsCount === 0)
|
||||
this.runtime.setMode('idle')
|
||||
this.runtime.transitionMode('idle', this.bot)
|
||||
}
|
||||
|
||||
this.deps.taskExecutor.on('action:started', onStarted)
|
||||
@@ -74,6 +74,9 @@ export class ReflexManager {
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.bot)
|
||||
this.runtime.transitionMode('idle', this.bot)
|
||||
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe()
|
||||
this.unsubscribe = null
|
||||
@@ -121,6 +124,22 @@ export class ReflexManager {
|
||||
})
|
||||
}
|
||||
|
||||
if (signal.type === 'chat_message') {
|
||||
const username = typeof (signal.metadata as any)?.username === 'string'
|
||||
? String((signal.metadata as any).username)
|
||||
: (signal.sourceId ?? null)
|
||||
|
||||
const message = typeof (signal.metadata as any)?.message === 'string'
|
||||
? String((signal.metadata as any).message)
|
||||
: null
|
||||
|
||||
this.runtime.getContext().updateSocial({
|
||||
lastSpeaker: username,
|
||||
lastMessage: message,
|
||||
lastMessageAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// If it's a chat message (simulated via signal for now, or direct?)
|
||||
// For now we rely on signal metadata or separate chat event.
|
||||
// Assuming 'signal:social:chat' or similar might exist later.
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MineflayerWithAgents } from '../types'
|
||||
import type { ReflexModeId } from './modes'
|
||||
import type { ReflexBehavior } from './types/behavior'
|
||||
|
||||
import { followPlayer } from '../../skills/movement'
|
||||
import { ReflexContext } from './context'
|
||||
import { selectMode } from './modes'
|
||||
|
||||
@@ -14,6 +15,7 @@ export class ReflexRuntime {
|
||||
private readonly runHistory = new Map<string, { lastRunAt: number }>()
|
||||
|
||||
private mode: ReflexModeId = 'idle'
|
||||
private lockedFollowTargetName: string | null = null
|
||||
private activeBehaviorId: string | null = null
|
||||
private activeBehaviorUntil: number | null = null
|
||||
|
||||
@@ -33,12 +35,92 @@ export class ReflexRuntime {
|
||||
return this.mode
|
||||
}
|
||||
|
||||
public setMode(mode: ReflexModeId): void {
|
||||
if (this.mode === mode)
|
||||
/**
|
||||
* Single entrypoint for mode changes. Runs onExit/onEnter side effects and notifies onModeChange
|
||||
* only when the mode actually changes. Pass bot when available so mode handlers can perform
|
||||
* movement/interrupt cleanup.
|
||||
*/
|
||||
public transitionMode(mode: ReflexModeId, bot: MineflayerWithAgents | null): void {
|
||||
if (mode === this.mode)
|
||||
return
|
||||
|
||||
this.mode = mode
|
||||
this.deps.onModeChange?.(mode)
|
||||
|
||||
const prev = this.mode
|
||||
this.onExitMode(prev, bot)
|
||||
this.mode = mode
|
||||
this.onEnterMode(mode, bot)
|
||||
}
|
||||
|
||||
private onEnterMode(mode: ReflexModeId, bot: MineflayerWithAgents | null): void {
|
||||
if (mode !== 'social')
|
||||
return
|
||||
|
||||
if (!bot)
|
||||
return
|
||||
|
||||
if (this.lockedFollowTargetName)
|
||||
return
|
||||
|
||||
const snap = this.context.getSnapshot()
|
||||
|
||||
const pickFromPlayers = (preferredName: string | null): string | null => {
|
||||
const selfPos = bot.bot.entity?.position
|
||||
if (!selfPos)
|
||||
return null
|
||||
|
||||
const inRange = (name: string): number | null => {
|
||||
const ent = bot.bot.players?.[name]?.entity
|
||||
const pos = ent?.position
|
||||
if (!pos)
|
||||
return null
|
||||
|
||||
try {
|
||||
const d = selfPos.distanceTo(pos)
|
||||
return d <= 16 ? d : null
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (preferredName) {
|
||||
const d = inRange(preferredName)
|
||||
if (typeof d === 'number')
|
||||
return preferredName
|
||||
}
|
||||
|
||||
let best: { name: string, dist: number } | null = null
|
||||
for (const name of Object.keys(bot.bot.players ?? {})) {
|
||||
if (!name || name === bot.bot.username)
|
||||
continue
|
||||
|
||||
const d = inRange(name)
|
||||
if (typeof d !== 'number')
|
||||
continue
|
||||
|
||||
if (!best || d < best.dist)
|
||||
best = { name, dist: d }
|
||||
}
|
||||
|
||||
return best?.name ?? null
|
||||
}
|
||||
|
||||
const preferred = snap.social.lastSpeaker
|
||||
const chosen = pickFromPlayers(preferred)
|
||||
if (!chosen)
|
||||
return
|
||||
|
||||
this.lockedFollowTargetName = chosen
|
||||
void followPlayer(bot, chosen)
|
||||
}
|
||||
|
||||
private onExitMode(mode: ReflexModeId, bot: MineflayerWithAgents | null): void {
|
||||
if (mode !== 'social')
|
||||
return
|
||||
|
||||
this.lockedFollowTargetName = null
|
||||
bot?.interrupt?.('reflex:social_exit')
|
||||
}
|
||||
|
||||
public getActiveBehaviorId(): string | null {
|
||||
@@ -77,8 +159,10 @@ export class ReflexRuntime {
|
||||
// Allow explicit modes like 'work' / 'wander' to remain until changed by caller.
|
||||
// Otherwise, compute from context automatically.
|
||||
// TODO: consider letting 'alert' preempt work/wander so survival can override tasks.
|
||||
if (this.mode !== 'work' && this.mode !== 'wander')
|
||||
this.setMode(selectMode(this.context.getSnapshot()))
|
||||
if (this.mode !== 'work' && this.mode !== 'wander') {
|
||||
const nextMode = selectMode(this.context.getSnapshot())
|
||||
this.transitionMode(nextMode, bot)
|
||||
}
|
||||
|
||||
if (this.activeBehaviorUntil && now < this.activeBehaviorUntil)
|
||||
return null
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
name: teabag-attention
|
||||
version: 1
|
||||
|
||||
trigger:
|
||||
modality: sighted
|
||||
kind: sneak_toggle
|
||||
where:
|
||||
entityType: player
|
||||
|
||||
accumulator:
|
||||
threshold: 5
|
||||
window: 2s
|
||||
mode: sliding
|
||||
|
||||
signal:
|
||||
type: entity_attention
|
||||
description: 'Player {{ displayName }} is teabagging (rapid sneaking)'
|
||||
confidence: 1.0
|
||||
metadata:
|
||||
action: teabag
|
||||
distance: '{{ distance }}'
|
||||
displayName: '{{ displayName }}'
|
||||
@@ -8,8 +8,8 @@ trigger:
|
||||
entityType: player
|
||||
|
||||
accumulator:
|
||||
threshold: 4
|
||||
window: 2s
|
||||
threshold: 6
|
||||
window: 1s
|
||||
mode: sliding
|
||||
|
||||
signal:
|
||||
|
||||
Reference in New Issue
Block a user