feat(minecraft): add social reflex, follow player on chat

fixup chat signal not propagated
This commit is contained in:
Rin
2026-02-18 11:12:19 +08:00
committed by Neko Ayaka
parent 50111bab56
commit e02f718aa2
7 changed files with 258 additions and 34 deletions
+21
View File
@@ -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: