From e02f718aa2d9169caa9ccaf21a1f412ca43fc22f Mon Sep 17 00:00:00 2001 From: Rin Date: Thu, 15 Jan 2026 21:18:17 +0800 Subject: [PATCH] feat(minecraft): add social reflex, follow player on chat fixup chat signal not propagated --- services/minecraft/src/cognitive/index.ts | 21 +++ .../src/cognitive/reflex/behaviors/look-at.ts | 2 +- .../cognitive/reflex/reflex-manager.test.ts | 126 +++++++++++++++++- .../src/cognitive/reflex/reflex-manager.ts | 23 +++- .../minecraft/src/cognitive/reflex/runtime.ts | 94 ++++++++++++- .../src/cognitive/rules/attention/teabag.yaml | 22 --- .../src/cognitive/rules/social/teabag.yaml | 4 +- 7 files changed, 258 insertions(+), 34 deletions(-) delete mode 100644 services/minecraft/src/cognitive/rules/attention/teabag.yaml diff --git a/services/minecraft/src/cognitive/index.ts b/services/minecraft/src/cognitive/index.ts index d1abde14d..59e3a73eb 100644 --- a/services/minecraft/src/cognitive/index.ts +++ b/services/minecraft/src/cognitive/index.ts @@ -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)) }) } diff --git a/services/minecraft/src/cognitive/reflex/behaviors/look-at.ts b/services/minecraft/src/cognitive/reflex/behaviors/look-at.ts index 2fb7ad91c..41dd6ac84 100644 --- a/services/minecraft/src/cognitive/reflex/behaviors/look-at.ts +++ b/services/minecraft/src/cognitive/reflex/behaviors/look-at.ts @@ -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 diff --git a/services/minecraft/src/cognitive/reflex/reflex-manager.test.ts b/services/minecraft/src/cognitive/reflex/reflex-manager.test.ts index 7855b51bd..c7208400c 100644 --- a/services/minecraft/src/cognitive/reflex/reflex-manager.test.ts +++ b/services/minecraft/src/cognitive/reflex/reflex-manager.test.ts @@ -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') + }) }) diff --git a/services/minecraft/src/cognitive/reflex/reflex-manager.ts b/services/minecraft/src/cognitive/reflex/reflex-manager.ts index a6295508d..7959edbf3 100644 --- a/services/minecraft/src/cognitive/reflex/reflex-manager.ts +++ b/services/minecraft/src/cognitive/reflex/reflex-manager.ts @@ -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. diff --git a/services/minecraft/src/cognitive/reflex/runtime.ts b/services/minecraft/src/cognitive/reflex/runtime.ts index a88c182bd..ace24a727 100644 --- a/services/minecraft/src/cognitive/reflex/runtime.ts +++ b/services/minecraft/src/cognitive/reflex/runtime.ts @@ -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() 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 diff --git a/services/minecraft/src/cognitive/rules/attention/teabag.yaml b/services/minecraft/src/cognitive/rules/attention/teabag.yaml deleted file mode 100644 index 466322166..000000000 --- a/services/minecraft/src/cognitive/rules/attention/teabag.yaml +++ /dev/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 }}' diff --git a/services/minecraft/src/cognitive/rules/social/teabag.yaml b/services/minecraft/src/cognitive/rules/social/teabag.yaml index 775dd9dea..a8c29923d 100644 --- a/services/minecraft/src/cognitive/rules/social/teabag.yaml +++ b/services/minecraft/src/cognitive/rules/social/teabag.yaml @@ -8,8 +8,8 @@ trigger: entityType: player accumulator: - threshold: 4 - window: 2s + threshold: 6 + window: 1s mode: sliding signal: