From 46b1a8d438a429bfda1d68fe1e97efb2c3f84040 Mon Sep 17 00:00:00 2001 From: Rin Date: Mon, 16 Feb 2026 03:19:53 +0800 Subject: [PATCH] refactor(minecraft): replace look-at and teabag behaviors with idle-gaze behavior --- .../cognitive/reflex/behaviors/idle-gaze.ts | 168 ++++++++++++++++++ .../src/cognitive/reflex/behaviors/look-at.ts | 42 ----- .../src/cognitive/reflex/behaviors/teabag.ts | 37 ---- .../src/cognitive/reflex/reflex-manager.ts | 6 +- 4 files changed, 170 insertions(+), 83 deletions(-) create mode 100644 services/minecraft/src/cognitive/reflex/behaviors/idle-gaze.ts delete mode 100644 services/minecraft/src/cognitive/reflex/behaviors/look-at.ts delete mode 100644 services/minecraft/src/cognitive/reflex/behaviors/teabag.ts diff --git a/services/minecraft/src/cognitive/reflex/behaviors/idle-gaze.ts b/services/minecraft/src/cognitive/reflex/behaviors/idle-gaze.ts new file mode 100644 index 000000000..17b1c67c0 --- /dev/null +++ b/services/minecraft/src/cognitive/reflex/behaviors/idle-gaze.ts @@ -0,0 +1,168 @@ +import type { Bot } from 'mineflayer' +import type { Entity } from 'prismarine-entity' + +import type { ReflexBehavior } from '../types/behavior' + +// Minimum velocity magnitude to consider a player "moving" +const MOVEMENT_THRESHOLD = 0.1 +// Max distance (blocks) to care about player movement +const GAZE_RANGE = 16 +// After looking at a moving player, don't re-trigger for this long +const COOLDOWN_MS = 2_000 +// Chance to skip a valid gaze trigger (so the bot doesn't robotically track every movement) +const IGNORE_CHANCE = 0.3 +// Attention events older than this are ignored to avoid stale reactions. +const ATTENTION_MAX_AGE_MS = 2_000 +// Skip tiny orientation changes to avoid visible head jitter. +const LOOK_DEADZONE_RAD = 0.07 +// Avoid rapid oscillation when switching between nearby candidates. +const RETARGET_DEBOUNCE_MS = 650 + +const smoothingByBot = new WeakMap() + +interface GazeCandidate { + entity: Entity + score: number + isAttention: boolean +} + +function horizontalSpeed(entity: Entity): number { + const v = entity.velocity + if (!v) + return 0 + return Math.sqrt(v.x * v.x + v.z * v.z) +} + +function headLookOffset(entity: Entity): number { + return Number.isFinite(entity.height) ? entity.height * 0.85 : 1.5 +} + +function normalizeAngle(rad: number): number { + let a = rad + while (a > Math.PI) a -= Math.PI * 2 + while (a < -Math.PI) a += Math.PI * 2 + return a +} + +function getLookDeltaRad(from: Entity, to: { x: number, y: number, z: number }): number { + const dx = to.x - from.position.x + const dy = to.y - from.position.y + const dz = to.z - from.position.z + const hDist = Math.hypot(dx, dz) + + const yawDelta = Math.abs(normalizeAngle(Math.atan2(-dx, -dz) - (from.yaw ?? 0))) + const pitchDelta = Math.abs(Math.atan2(dy, hDist) - (from.pitch ?? 0)) + return Math.max(yawDelta, pitchDelta) +} + +function getSmoothingState(bot: object): { lastTargetId: number | null, lastLookAtAt: number } { + let state = smoothingByBot.get(bot) + if (!state) { + state = { lastTargetId: null, lastLookAtAt: 0 } + smoothingByBot.set(bot, state) + } + return state +} + +/** + * Finds the best gaze target: either a recent attention entity or the closest moving player. + * Returns null when there is nothing worth looking at. + */ +function findGazeTarget( + bot: Bot, + ctx: Parameters[0], +): GazeCandidate | null { + const selfPos = bot.entity?.position + if (!selfPos) + return null + + // 1. Recent attention signal takes priority + if ( + ctx.attention.lastSignalType === 'entity_attention' + && ctx.attention.lastSignalAt + && ctx.now - ctx.attention.lastSignalAt <= ATTENTION_MAX_AGE_MS + && ctx.attention.lastSignalSourceId + ) { + const target = bot.entities[Number(ctx.attention.lastSignalSourceId)] + if (target) { + const dist = selfPos.distanceTo(target.position) + if (dist <= GAZE_RANGE) + return { entity: target, score: 25 + 35 * (1 - dist / GAZE_RANGE), isAttention: true } + } + } + + // 2. Closest moving player + let best: GazeCandidate | null = null + let bestDist = Infinity + + for (const [name, player] of Object.entries(bot.players)) { + if (name === bot.username) + continue + const entity = player?.entity + if (!entity || horizontalSpeed(entity) <= MOVEMENT_THRESHOLD) + continue + + const dist = selfPos.distanceTo(entity.position) + if (dist > GAZE_RANGE) + continue + + if (dist < bestDist) { + bestDist = dist + best = { entity, score: 10 + 30 * (1 - dist / GAZE_RANGE), isAttention: false } + } + } + + return best +} + +/** + * Idle-gaze: the bot occasionally glances at nearby players who are moving. + * + * Design goals for realism: + * - Only fires in idle/social modes (not during work, combat, etc.) + * - Ignores stationary players — no creepy staring + * - Drawn to movement: picks the closest moving player + * - Probabilistic skip so the bot doesn't track every single movement + * - Smooth look turns with anti-jitter retarget guards + */ +export const idleGazeBehavior: ReflexBehavior = { + id: 'idle-gaze', + modes: ['idle', 'social'], + cooldownMs: COOLDOWN_MS, + + when: (ctx, api) => api ? findGazeTarget(api.bot.bot, ctx) !== null : false, + + score: (ctx, api) => api ? (findGazeTarget(api.bot.bot, ctx)?.score ?? 0) : 0, + + run: async ({ bot: mfBot, context }) => { + const bot = mfBot.bot + const selfEntity = bot.entity + if (!selfEntity) + return + + const candidate = findGazeTarget(bot, context.getSnapshot()) + if (!candidate) + return + + // Probabilistic skip for ambient movement scans (attention signals always fire). + if (!candidate.isAttention && Math.random() < IGNORE_CHANCE) + return + + const targetId = candidate.entity.id ?? null + const lookTarget = candidate.entity.position.offset(0, headLookOffset(candidate.entity), 0) + + if (getLookDeltaRad(selfEntity, lookTarget) < LOOK_DEADZONE_RAD) + return + + const smoothing = getSmoothingState(bot) + const now = Date.now() + const isSwitch = smoothing.lastTargetId !== null && targetId !== null && smoothing.lastTargetId !== targetId + if (isSwitch && now - smoothing.lastLookAtAt < RETARGET_DEBOUNCE_MS) + return + + // force=false uses mineflayer's smoother turn path instead of snap rotation. + await bot.lookAt(lookTarget, false) + smoothing.lastTargetId = targetId + smoothing.lastLookAtAt = now + }, +} diff --git a/services/minecraft/src/cognitive/reflex/behaviors/look-at.ts b/services/minecraft/src/cognitive/reflex/behaviors/look-at.ts deleted file mode 100644 index 41dd6ac84..000000000 --- a/services/minecraft/src/cognitive/reflex/behaviors/look-at.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ReflexBehavior } from '../types/behavior' - -export const lookAtBehavior: ReflexBehavior = { - id: 'look-at', - modes: ['idle', 'social'], - cooldownMs: 100, - - when: (ctx) => { - // Check if we have a recent attention signal - const { lastSignalType, lastSignalAt } = ctx.attention - if (!lastSignalType || !lastSignalAt) - return false - - // Must be fresh (within 2 seconds) - if (ctx.now - lastSignalAt > 2000) - return false - - // Respond to entity_attention signals - return lastSignalType === 'entity_attention' - }, - - score: () => { - // High priority but not override-level (100) - // Allows critical survival behaviors to take precedence - return 50 - }, - - run: async ({ bot, context }) => { - const { lastSignalSourceId } = context.getSnapshot().attention - - if (!lastSignalSourceId) - return - - // Find the entity - const target = bot.bot.entities[Number(lastSignalSourceId)] - if (!target) - return - - // Look at the entity smoothly - await bot.bot.lookAt(target.position.offset(0, target.height * 0.85, 0), true) - }, -} diff --git a/services/minecraft/src/cognitive/reflex/behaviors/teabag.ts b/services/minecraft/src/cognitive/reflex/behaviors/teabag.ts deleted file mode 100644 index c6ed6af80..000000000 --- a/services/minecraft/src/cognitive/reflex/behaviors/teabag.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ReflexBehavior } from '../types/behavior' - -export const teabagBehavior: ReflexBehavior = { - id: 'teabag', - modes: ['social', 'idle'], - cooldownMs: 5000, - - when: (_ctx, api) => { - // Check if any player is teabagging with high confidence - if (!api?.perception) - return false - const teabaggers = api.perception.entitiesWithBelief('teabag', 0.6) - return teabaggers.length > 0 - }, - - score: (_ctx, api) => { - // Higher priority than LookAt (50), scaled by confidence - if (!api?.perception) - return 0 - const teabaggers = api.perception.entitiesWithBelief('teabag', 0.6) - if (teabaggers.length === 0) - return 0 - // Use highest confidence as score boost - const maxConfidence = Math.max(...teabaggers.map(e => e.beliefs.teabag?.confidence ?? 0)) - return 60 + (maxConfidence * 20) - }, - - run: async ({ bot }) => { - // Perform rapid squats - for (let i = 0; i < 4; i++) { - bot.bot.setControlState('sneak', true) - await new Promise(resolve => setTimeout(resolve, 150)) - bot.bot.setControlState('sneak', false) - await new Promise(resolve => setTimeout(resolve, 150)) - } - }, -} diff --git a/services/minecraft/src/cognitive/reflex/reflex-manager.ts b/services/minecraft/src/cognitive/reflex/reflex-manager.ts index b4a1e4a7f..41ff6f158 100644 --- a/services/minecraft/src/cognitive/reflex/reflex-manager.ts +++ b/services/minecraft/src/cognitive/reflex/reflex-manager.ts @@ -8,8 +8,7 @@ import type { MineflayerWithAgents } from '../types' import type { ReflexContextState } from './context' import { DebugService } from '../../debug' -import { lookAtBehavior } from './behaviors/look-at' -import { teabagBehavior } from './behaviors/teabag' +import { idleGazeBehavior } from './behaviors/idle-gaze' import { ReflexRuntime } from './runtime' export class ReflexManager { @@ -33,8 +32,7 @@ export class ReflexManager { onModeChange: () => this.emitReflexState(), }) - this.runtime.registerBehavior(lookAtBehavior) - this.runtime.registerBehavior(teabagBehavior) + this.runtime.registerBehavior(idleGazeBehavior) } public init(bot: MineflayerWithAgents): void {