diff --git a/src/controls.ts b/src/controls.ts index 83458dee3..db6099aea 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -31,6 +31,7 @@ import { switchGameMode } from './packetsReplay/replayPackets' import { tabListState } from './react/PlayerListOverlayProvider' import { type ActionType, type ActionHoldConfig, type CustomAction } from './appConfig' import { playerState } from './mineflayer/playerState' +import { shouldBlockSprint, shouldSlowWhileUsing } from './mineflayer/movementWhileUsing' import { setTalking, toggleTalking } from './voice/voiceChat' import { voiceChatStatus } from './react/VoiceMicrophone' import { emulateMouseClick } from './app/gamepadCursor' @@ -144,11 +145,139 @@ subscribe(miscUiState, updateDoPreventDefault) subscribe(activeModalStack, updateDoPreventDefault) updateDoPreventDefault() +type MovementControl = 'forward' | 'back' | 'left' | 'right' +const movementControls = ['forward', 'back', 'left', 'right'] as const satisfies readonly MovementControl[] +const movementPulsePeriodTicks = 5 +const movementPulseIntervalMs = 50 +const desiredMovementState: Record = { + forward: false, + back: false, + left: false, + right: false, +} +let movementPulsePhase = 0 +let movementPulseTimer: number | undefined +let movementGuardsBot: typeof bot | undefined +let removeMovementUseSubscription: (() => void) | undefined + +const forceSprintOff = () => { + if (!bot) return + bot.setControlState('sprint', false) + gameAdditionalState.isSprinting = false +} + const setSprinting = (state: boolean) => { + if (state && shouldBlockSprint(playerState.reactive?.itemUseSession)) { + forceSprintOff() + return + } bot.setControlState('sprint', state) gameAdditionalState.isSprinting = state } +const applyMovementControls = () => { + if (!bot) return + + const slowInput = shouldSlowWhileUsing(playerState.reactive?.itemUseSession, Boolean(bot.vehicle)) + const suppressInput = slowInput && movementPulsePhase !== 0 + for (const key of movementControls) { + const nextState = desiredMovementState[key] && !suppressInput + if (nextState === Boolean(bot.controlState[key])) continue + bot.setControlState(key, nextState) + } +} + +const refreshMovementControls = () => { + if (!bot) return + + const slowInput = shouldSlowWhileUsing(playerState.reactive?.itemUseSession, Boolean(bot.vehicle)) + const hasMovementInput = movementControls.some(key => desiredMovementState[key]) + if (slowInput && hasMovementInput) { + if (!movementPulseTimer) { + // Mineflayer exposes boolean control states, not LocalPlayer's analog + // impulses. One enabled tick followed by four suppressed ticks is the + // closest input-stage equivalent to vanilla's * 0.2. + movementPulsePhase = 0 + movementPulseTimer = window.setInterval(() => { + const stillSlow = shouldSlowWhileUsing(playerState.reactive?.itemUseSession, Boolean(bot.vehicle)) + const stillMoving = movementControls.some(key => desiredMovementState[key]) + if (!stillSlow || !stillMoving) { + if (movementPulseTimer) clearInterval(movementPulseTimer) + movementPulseTimer = undefined + movementPulsePhase = 0 + applyMovementControls() + return + } + movementPulsePhase = (movementPulsePhase + 1) % movementPulsePeriodTicks + applyMovementControls() + }, movementPulseIntervalMs) + } + } else { + if (movementPulseTimer) { + clearInterval(movementPulseTimer) + movementPulseTimer = undefined + } + movementPulsePhase = 0 + } + applyMovementControls() +} + +const setupMovementUseGuards = () => { + if (!bot || movementGuardsBot === bot) return + + removeMovementUseSubscription?.() + removeMovementUseSubscription = undefined + movementGuardsBot = bot + movementControls.forEach(key => { + desiredMovementState[key] = false + }) + if (movementPulseTimer) { + clearInterval(movementPulseTimer) + movementPulseTimer = undefined + } + movementPulsePhase = 0 + + const originalSetControlState = bot.setControlState.bind(bot) + bot.setControlState = (control, state) => { + if (control === 'sprint' && state && shouldBlockSprint(playerState.reactive?.itemUseSession)) { + originalSetControlState('sprint', false) + gameAdditionalState.isSprinting = false + return + } + originalSetControlState(control, state) + if (control === 'sprint') { + gameAdditionalState.isSprinting = state + } + } + + if (playerState.reactive) { + removeMovementUseSubscription = subscribe(playerState.reactive, (ops) => { + if (!ops.some(([, path]) => path[0] === 'itemUseSession')) return + if (shouldBlockSprint(playerState.reactive?.itemUseSession)) { + forceSprintOff() + } + refreshMovementControls() + }, true) + } + + if (shouldBlockSprint(playerState.reactive?.itemUseSession)) { + forceSprintOff() + } + refreshMovementControls() +} + +customEvents.on('mineflayerBotCreated', () => { + bot.once('inject_allowed', () => { + // viewerConnector installs its own setControlState wrapper at inject time; + // install this outer guard after that wrapper so pressure/mobile callers + // cannot send a sprint=true request around the using-item check. + setTimeout(setupMovementUseGuards, 0) + }) +}) +customEvents.on('gameLoaded', () => { + setTimeout(setupMovementUseGuards, 0) +}) + const isSpectatingEntity = () => { return getPlayerStateUtils(playerState.reactive).isSpectatingEntity() } @@ -197,29 +326,27 @@ contro.on('movementUpdate', ({ vector, soleVector, gamepadIndex }) => { ['x', 1, 'right'], ] as const - const newState: Partial = {} + const newState: Partial> = {} for (const [coord, v] of Object.entries(vector)) { if (v === undefined || Math.abs(v) < 0.3) continue - // todo use raw values eg for slow movement + // ControMax supplies analog vectors, but mineflayer's controlState is + // boolean-only; retain direction here and apply the using-item pulse below. const mappedValue = v < 0 ? -1 : 1 - // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - const foundAction = coordToAction.find(([c, mapV]) => c === coord && mapV === mappedValue)?.[2]! + const foundAction = coordToAction.find(([c, mapV]) => c === coord && mapV === mappedValue)?.[2] + if (!foundAction) continue newState[foundAction] = true } - for (const key of ['forward', 'back', 'left', 'right'] as const) { - if (!!(newState[key]) === !!(bot.controlState[key])) continue + for (const key of movementControls) { const action = !!newState[key] if (action && !isGameActive(true)) continue - bot.setControlState(key, action) - - if (key === 'forward') { + desiredMovementState[key] = action + if (key === 'forward' && !action) { // todo workaround: need to refactor - if (!action) { - setSprinting(false) - } + setSprinting(false) } } + refreshMovementControls() }) let lastCommandTrigger = null as { command: string, time: number } | null @@ -242,6 +369,10 @@ subscribe(activeModalStack, () => { for (const key of contro.pressedKeys) { contro.pressedKeyOrButtonChanged({ code: key }, false) } + for (const key of movementControls) { + desiredMovementState[key] = false + } + refreshMovementControls() } }) diff --git a/src/mineflayer/movementWhileUsing.test.ts b/src/mineflayer/movementWhileUsing.test.ts new file mode 100644 index 000000000..533bb515b --- /dev/null +++ b/src/mineflayer/movementWhileUsing.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'vitest' +import type { UseItemAction, UseItemSession } from 'minecraft-renderer/src/playerState/types' +import { shouldBlockSprint, shouldSlowWhileUsing } from './movementWhileUsing' + +const makeSession = (action: UseItemAction = 'EAT', status: UseItemSession['status'] = 'active'): UseItemSession => ({ + id: 1, + itemSnapshot: { + name: action === 'BOW' ? 'bow' : 'bread', + durationTicks: 32, + action, + particleEffect: action === 'EAT' ? 'food' : 'none', + }, + hand: 0, + action, + durationTicks: 32, + elapsedTicks: 0, + status, +}) + +describe('movement restrictions while using an item', () => { + test('requires an active or awaiting-completion session', () => { + expect(shouldSlowWhileUsing(undefined, false)).toBe(false) + expect(shouldBlockSprint(undefined)).toBe(false) + expect(shouldSlowWhileUsing(makeSession('EAT', 'completed'), false)).toBe(false) + expect(shouldBlockSprint(makeSession('EAT', 'cancelled'))).toBe(false) + expect(shouldSlowWhileUsing(makeSession('DRINK', 'awaitingCompletion'), false)).toBe(true) + expect(shouldBlockSprint(makeSession('EAT', 'active'))).toBe(true) + }) + + test('matches represented vanilla use actions, including bow draw', () => { + for (const action of ['EAT', 'DRINK', 'BOW', 'CROSSBOW', 'SHIELD'] as const) { + expect(shouldSlowWhileUsing(makeSession(action), false)).toBe(true) + expect(shouldBlockSprint(makeSession(action))).toBe(true) + } + expect(shouldSlowWhileUsing(makeSession('NONE'), false)).toBe(false) + expect(shouldBlockSprint(makeSession('NONE'))).toBe(false) + }) + + test('mounted players keep normal movement but still cannot sprint while using', () => { + const session = makeSession('BOW') + expect(shouldSlowWhileUsing(session, true)).toBe(false) + expect(shouldBlockSprint(session)).toBe(true) + }) +}) diff --git a/src/mineflayer/movementWhileUsing.ts b/src/mineflayer/movementWhileUsing.ts new file mode 100644 index 000000000..6d96d1bb9 --- /dev/null +++ b/src/mineflayer/movementWhileUsing.ts @@ -0,0 +1,34 @@ +import type { UseItemAction, UseItemSession } from 'minecraft-renderer/src/playerState/types' + +/** + * These are the use actions represented by the local item-use session. `NONE` + * is used for activatable inventory items that do not have a vanilla use + * animation; it is not a LocalPlayer.isUsingItem() state and must not apply + * the movement restriction. + */ +const MOVEMENT_RESTRICTED_USE_ACTIONS: Record = { + EAT: true, + DRINK: true, + BOW: true, + CROSSBOW: true, + SHIELD: true, + NONE: undefined, +} + +const isActiveUseSession = (session: UseItemSession | null | undefined): boolean => { + if (!session || (session.status !== 'active' && session.status !== 'awaitingCompletion')) { + return false + } + return MOVEMENT_RESTRICTED_USE_ACTIONS[session.action] === true +} + +/** Vanilla's input slow applies to unmounted players while an item is used. */ +export const shouldSlowWhileUsing = ( + session: UseItemSession | null | undefined, + hasVehicle: boolean +): boolean => !hasVehicle && isActiveUseSession(session) + +/** Vanilla sprint eligibility is blocked for every active represented use. */ +export const shouldBlockSprint = (session: UseItemSession | null | undefined): boolean => { + return isActiveUseSession(session) +} diff --git a/src/mineflayer/playerState.ts b/src/mineflayer/playerState.ts index 99f6951ae..d0d9e68da 100644 --- a/src/mineflayer/playerState.ts +++ b/src/mineflayer/playerState.ts @@ -2,13 +2,66 @@ import { getInitialPlayerState, getPlayerStateUtils, PlayerStateReactive, Player import { states } from 'minecraft-protocol' import { subscribe } from 'valtio' import { subscribeKey } from 'valtio/utils' -import { HandItemBlock } from 'minecraft-renderer/src/playerState/types' +import { HandItemBlock, UseItemAction, UseItemParticleEffect, UseItemSession, UseItemSnapshot } from 'minecraft-renderer/src/playerState/types' import { beforeRenderFrame } from '../beforeRenderFrame' import { gameAdditionalState } from '../globalState' import { options } from '../optionsStorage' import { getCameraMovementMode } from '../cameraMovementMode' import { updateMountedBobState, updateMountedMovementState } from '../mountedPlayerState' +import { getThreeJsRendererMethods } from 'minecraft-renderer/src/three/threeJsMethods' +import { buildUseEffectBoundaries, getCrossedUseEffectBoundaries, getUseEffectDescriptor, type UseEffectPhase } from './useItemEffects' +import { canUseItemAtHunger } from './useHungerGate' + +type UseEffectRecord = { + boundaries: Set + fired: Set +} + +const FAST_FOOD_ITEM_NAMES: Record = { + dried_kelp: true, +} + +type UseItemHand = 0 | 1 +type UseItemEventItem = { name?: string | null } | null | undefined + +const DRINK_ITEM_NAMES: Record = { + honey_bottle: true, + milk_bucket: true, + potion: true, +} + +const getUseItemSnapshot = (name: string): UseItemSnapshot => { + let action: UseItemAction = 'NONE' + let durationTicks = 0 + let particleEffect: UseItemParticleEffect = 'none' + + if (name === 'bow') { + action = 'BOW' + durationTicks = 20 + } else if (name === 'crossbow') { + action = 'CROSSBOW' + durationTicks = 20 + } else if (name === 'shield') { + action = 'SHIELD' + } else if (DRINK_ITEM_NAMES[name]) { + action = 'DRINK' + durationTicks = name === 'honey_bottle' ? 40 : 32 + particleEffect = 'drink' + } else if (typeof loadedData !== 'undefined' && loadedData?.foodsArray?.some(food => food.name === name)) { + action = 'EAT' + durationTicks = name === 'dried_kelp' ? 16 : 32 + particleEffect = 'food' + } + + return { + name, + durationTicks, + action, + particleEffect, + } +} + const BASE_MOVEMENT_SPEED = 0.1 const FOV_EFFECT_SCALE = 1 const ZOOM_FOV = 30 @@ -42,7 +95,9 @@ const updateFovMultiplier = () => { } const heldItem = playerState.reactive.heldItemMain - if (heldItem?.name === 'bow' && playerState.reactive.itemUsageTicks > 0) { + const usingBow = playerState.reactive.itemUseSession?.action === 'BOW' + || (heldItem?.name === 'bow' && playerState.reactive.itemUsageTicks > 0) + if (usingBow && playerState.reactive.itemUsageTicks > 0) { let usageProgress = playerState.reactive.itemUsageTicks / 20 if (usageProgress > 1) { usageProgress = 1 @@ -78,8 +133,16 @@ export class PlayerStateControllerMain { private lastUpdateTime = performance.now() // Held item state - private isUsingItem = false + private nextUseSessionId = 0 + private activeUseSlot: number | null = null + private useEffectRecords = new Map() private eyeHeightWatchInstalled = false + + /** Vanilla isUsingItem: still using until cancel or event-9, including remaining=0. */ + private get isUsingItem (): boolean { + const status = this.reactive?.itemUseSession?.status + return status === 'active' || status === 'awaitingCompletion' + } ready = false reactive: PlayerStateReactive @@ -97,7 +160,7 @@ export class PlayerStateControllerMain { */ private attachBotSession () { this.ready = false - this.isUsingItem = false + if (this.reactive) this.resetUseSession() this.timeOffGround = 0 this.lastUpdateTime = performance.now() @@ -114,6 +177,7 @@ export class PlayerStateControllerMain { bot.once('inject_allowed', onInjectAllowed) bot.once('end', () => { + this.resetUseSession() this.ready = false }) @@ -141,6 +205,9 @@ export class PlayerStateControllerMain { Object.assign(this.reactive, fresh) this.reactive.perspective = options.defaultPerspective this.onBotCreatedOrGameJoined() + bot.on('death', () => this.resetUseSession()) + bot.on('respawn', () => this.resetUseSession()) + bot.on('kicked', () => this.resetUseSession()) const handleDimensionData = (data) => { let hasSkyLight = 1 @@ -166,16 +233,16 @@ export class PlayerStateControllerMain { }) // Movement tracking - bot.on('move', () => { - this.updateMovementState() - }) - // Item tracking - bot.on('heldItemChanged', () => { - return this.updateHeldItem(false) + bot.on('heldItemChanged', (item) => { + this.updateHeldItem(false) + this.cancelUseForHeldItem(0, item?.name) }) bot.inventory.on('updateSlot', (index) => { - if (index === 45) this.updateHeldItem(true) + if (index === 45) { + this.updateHeldItem(true) + this.cancelUseForHeldItem(1, bot.inventory.slots[45]?.name) + } }) const updateSneakingOrFlying = () => { this.updateMovementState() @@ -185,7 +252,7 @@ export class PlayerStateControllerMain { } updateSneakingOrFlying() bot.on('physicsTick', () => { - if (this.isUsingItem) this.reactive.itemUsageTicks++ + this.advanceUse() updateSneakingOrFlying() this.updateWalkDistAndBob() }) @@ -202,11 +269,13 @@ export class PlayerStateControllerMain { this.updateHeldItem(true) bot.on('game', () => { + this.resetUseSession() this.reactive.gameMode = bot.game.gameMode }) this.reactive.gameMode = bot.game?.gameMode customEvents.on('gameLoaded', () => { + this.resetUseSession() this.reactive.team = bot.teamMap[bot.username] as any }) @@ -292,15 +361,201 @@ export class PlayerStateControllerMain { // this.events.emit('heldItemChanged', item, isLeftHand) } - startUsingItem () { - if (this.isUsingItem) return - this.isUsingItem = true - this.reactive.itemUsageTicks = 0 + private publishItemUsageTicks (session = this.reactive?.itemUseSession) { + const usageContinues = session?.status === 'active' || session?.status === 'awaitingCompletion' + this.reactive.itemUsageTicks = usageContinues && session + ? Math.min(session.elapsedTicks, session.durationTicks) + : 0 + } + private dropUseEffectRecord (sessionId: number) { + this.useEffectRecords.delete(sessionId) + } + + private emitUseEffect (session: UseItemSession, phase: UseEffectPhase) { + const descriptor = getUseEffectDescriptor(session.action, session.itemSnapshot.name, phase) + if (descriptor.particleCount > 0) { + const position = bot.entity?.position + if (position) { + const renderer = getThreeJsRendererMethods() + if (renderer?.spawnItemParticles) { + // LivingEntity.spawnItemParticles uses eye-relative item particles; + // pass the same eye-height anchor while keeping the session snapshot + // as the texture identity after depletion/replacement. + void renderer.spawnItemParticles( + position.x, + position.y + (this.reactive?.eyeHeight ?? STANDING_EYE_HEIGHT), + position.z, + session.itemSnapshot.name, + descriptor.particleCount + ) + } + } + } + + // Player.playSound (1.17.1) calls level.playSound(this, ...). On the server that + // argument is the except-player, so sound_effect is not sent to the eater. + // On the client it is the local player, so ClientLevel plays it locally. + // Re-enter botSoundSystem via soundEffectHeard; particles stay client-only. + const position = bot.entity?.position + if (descriptor.sound && position) { + bot.emit('soundEffectHeard', descriptor.sound, position, 0.5, 1) + } + if (descriptor.burp && position) { + bot.emit('soundEffectHeard', 'entity.player.burp', position, 0.5, 1) + } + } + + private fireCrossedUseEffects (session: UseItemSession, previousElapsedTicks: number, nextElapsedTicks: number) { + const record = this.useEffectRecords.get(session.id) + if (!record) return + + // Catch up every missed boundary in descending remaining-tick order; + // repeated calls with the same elapsed value return no new boundaries. + for (const remaining of getCrossedUseEffectBoundaries( + previousElapsedTicks, + nextElapsedTicks, + session.durationTicks, + record.boundaries, + record.fired + )) { + record.fired.add(remaining) + this.emitUseEffect(session, 'periodic') + } } - stopUsingItem () { - this.isUsingItem = false - this.reactive.itemUsageTicks = 0 + + private cancelUseForHeldItem (hand: UseItemHand, name?: string) { + const session = this.reactive?.itemUseSession + if (!session || (session.status !== 'active' && session.status !== 'awaitingCompletion') || session.hand !== hand) return + const slot = hand === 1 ? 45 : bot.quickBarSlot + if (this.activeUseSlot !== null && slot !== this.activeUseSlot) { + this.cancelUse(undefined, hand) + return + } + if (name && name !== session.itemSnapshot.name) this.cancelUse(undefined, hand) + } + + private matchesUseEvent (session: UseItemSession, item: UseItemEventItem, hand?: UseItemHand) { + return (hand === undefined || session.hand === hand) + && (!item?.name || item.name === session.itemSnapshot.name) + } + + beginUse (item?: UseItemEventItem, hand: UseItemHand = 0): UseItemSession | undefined { + if (!this.reactive) return undefined + + const current = this.reactive.itemUseSession + // Vanilla startUsingItem requires !isUsingItem(); awaitingCompletion is still using. + if (this.isUsingItem) return current + + const heldItem = hand === 1 ? bot.inventory.slots[45] : bot.heldItem + const name = item?.name ?? heldItem?.name + if (!name) return undefined + + const itemSnapshot = getUseItemSnapshot(name) + // Mineflayer's game plugin exposes gameMode but does not populate a bot + // abilities.invulnerable field, so creative is the available vanilla equivalent. + if (!canUseItemAtHunger(itemSnapshot.action, name, bot.food ?? undefined, { + isCreative: bot.game?.gameMode === 'creative', + })) return undefined + + this.activeUseSlot = hand === 1 ? 45 : bot.quickBarSlot + + const session: UseItemSession = { + id: ++this.nextUseSessionId, + itemSnapshot, + hand, + action: itemSnapshot.action, + durationTicks: itemSnapshot.durationTicks, + elapsedTicks: 0, + status: 'active', + } + this.useEffectRecords.set(session.id, { + boundaries: buildUseEffectBoundaries(session.durationTicks, FAST_FOOD_ITEM_NAMES[name] === true), + fired: new Set(), + }) + this.reactive.itemUseSession = session + this.publishItemUsageTicks(session) + return session + } + + advanceUse (): UseItemSession | undefined { + const session = this.reactive?.itemUseSession + if (!session || session.status !== 'active') return session + + if (session.durationTicks > 0) { + const previousElapsedTicks = session.elapsedTicks + const nextElapsedTicks = Math.min(session.durationTicks, previousElapsedTicks + 1) + session.elapsedTicks = nextElapsedTicks + this.fireCrossedUseEffects(session, previousElapsedTicks, nextElapsedTicks) + if (session.elapsedTicks >= session.durationTicks && (session.action === 'EAT' || session.action === 'DRINK')) { + session.status = 'awaitingCompletion' + } + } + this.publishItemUsageTicks(session) + return session + } + + cancelUse (item?: UseItemEventItem, hand?: UseItemHand): UseItemSession | undefined { + const session = this.reactive?.itemUseSession + if (!session || !this.matchesUseEvent(session, item, hand)) return session + if (session.status === 'completed' || session.status === 'cancelled') return session + + this.dropUseEffectRecord(session.id) + session.status = 'cancelled' + this.activeUseSlot = null + this.publishItemUsageTicks(session) + return session + } + + completeUse (): UseItemSession | undefined { + const session = this.reactive?.itemUseSession + if (!session) return session + if (session.status === 'cancelled' || session.status === 'completed') { + this.dropUseEffectRecord(session.id) + return session + } + if (session.status !== 'active' && session.status !== 'awaitingCompletion') return session + if (session.action !== 'EAT' && session.action !== 'DRINK') return session + + session.status = 'completed' + this.activeUseSlot = null + this.publishItemUsageTicks(session) + this.emitUseEffect(session, 'finish') + this.dropUseEffectRecord(session.id) + return session + } + + resetUseSession () { + this.activeUseSlot = null + this.useEffectRecords.clear() + if (this.reactive) { + this.reactive.itemUseSession = undefined + this.reactive.itemUsageTicks = 0 + } + } + + startUsingItem (item?: UseItemEventItem, hand: UseItemHand = 0) { + return this.beginUse(item, hand) + } + + stopUsingItem (item?: UseItemEventItem, hand?: UseItemHand) { + const session = this.reactive?.itemUseSession + if (!session || !this.matchesUseEvent(session, item, hand)) return session + if (session.status === 'active') { + if ((session.action === 'EAT' || session.action === 'DRINK') + && session.durationTicks > 0 + && session.elapsedTicks >= session.durationTicks) { + session.status = 'awaitingCompletion' + } else { + session.status = 'cancelled' + } + } else if (session.status === 'awaitingCompletion' && session.action !== 'EAT' && session.action !== 'DRINK') { + session.status = 'cancelled' + } + if (session.status === 'cancelled') this.dropUseEffectRecord(session.id) + this.activeUseSlot = null + this.publishItemUsageTicks(session) + return session } getItemUsageTicks (): number { diff --git a/src/mineflayer/plugins/mouse.ts b/src/mineflayer/plugins/mouse.ts index 13be93d3e..321b5d984 100644 --- a/src/mineflayer/plugins/mouse.ts +++ b/src/mineflayer/plugins/mouse.ts @@ -53,10 +53,10 @@ export default (bot: Bot) => { domListeners(bot) cursorBlockDisplay(bot) - otherListeners() + otherListeners(bot) } -const otherListeners = () => { +const otherListeners = (bot: Bot) => { bot.on('startDigging', (block) => { customEvents.emit('digStart') }) @@ -75,11 +75,16 @@ const otherListeners = () => { bot.on('startUsingItem', (item, slot, isOffhand, duration) => { customEvents.emit('activateItem', item, isOffhand ? 45 : bot.quickBarSlot, isOffhand) - playerState.startUsingItem() + playerState.startUsingItem(item, isOffhand ? 1 : 0) }) - bot.on('stopUsingItem', () => { - playerState.stopUsingItem() + bot.on('stopUsingItem', (item, slot, isOffhand) => { + playerState.stopUsingItem(item, isOffhand ? 1 : 0) + }) + + bot._client.on('entity_status', (data) => { + if (data.entityId !== bot.entity?.id || data.entityStatus !== 9) return + playerState.completeUse() }) } diff --git a/src/mineflayer/useHungerGate.test.ts b/src/mineflayer/useHungerGate.test.ts new file mode 100644 index 000000000..d4cfc1cb1 --- /dev/null +++ b/src/mineflayer/useHungerGate.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'vitest' +import { canEatFood, canUseItemAtHunger } from './useHungerGate' + +describe('food hunger gate', () => { + test('blocks ordinary food at full hunger and allows it below full', () => { + expect(canEatFood('bread', 20)).toBe(false) + expect(canEatFood('bread', 19)).toBe(true) + }) + + test('allows all four vanilla always-edible foods at full hunger', () => { + for (const name of ['chorus_fruit', 'enchanted_golden_apple', 'golden_apple', 'suspicious_stew']) { + expect(canEatFood(name, 20)).toBe(true) + } + }) + + test('creative and explicitly always-edible inputs bypass the hunger check', () => { + expect(canEatFood('bread', 20, { isCreative: true })).toBe(true) + expect(canEatFood('custom_food', 20, { alwaysEdible: true })).toBe(true) + }) + + test('undefined food level is allowed before the first spawn update', () => { + expect(canEatFood('bread', undefined)).toBe(true) + }) + + test('only the EAT action is hunger-gated, so drinks and other actions bypass by action', () => { + for (const name of ['honey_bottle', 'milk_bucket', 'potion']) { + expect(canUseItemAtHunger('DRINK', name, 20)).toBe(true) + } + for (const action of ['BOW', 'CROSSBOW', 'SHIELD', 'NONE'] as const) { + expect(canUseItemAtHunger(action, 'bread', 20)).toBe(true) + } + expect(canUseItemAtHunger('EAT', 'honey_bottle', 20)).toBe(false) + }) + + test('saturation and health do not affect ordinary-food hunger gating', () => { + expect(canEatFood('bread', 20)).toBe(false) + expect(canEatFood('bread', 19)).toBe(true) + }) +}) diff --git a/src/mineflayer/useHungerGate.ts b/src/mineflayer/useHungerGate.ts new file mode 100644 index 000000000..2e2a97a04 --- /dev/null +++ b/src/mineflayer/useHungerGate.ts @@ -0,0 +1,51 @@ +import type { UseItemAction } from 'minecraft-renderer/src/playerState/types' + +export type HungerGateOptions = { + isCreative?: boolean + alwaysEdible?: boolean +} + +const ALWAYS_EDIBLE_FOOD_NAMES: Record = { + chorus_fruit: true, + enchanted_golden_apple: true, + golden_apple: true, + suspicious_stew: true, +} + +/** + * Return whether vanilla permits the food action at the current hunger level. + * + * Verified against 1.17.1 with `git show 1.17.1:`: + * - `minecraft/src/net/minecraft/world/entity/player/Player.java:1629-1630` + * implements `canEat(boolean)` as `abilities.invulnerable || bl || needsFood()`. + * - `minecraft/src/net/minecraft/world/food/FoodData.java:100-101` implements + * `needsFood()` as `foodLevel < 20`. + * - `minecraft/src/net/minecraft/world/food/Foods.java:20,31-47,81` marks + * chorus fruit, enchanted golden apple, golden apple, and suspicious stew as + * `alwaysEat`; honey at line 48 is not marked always-edible. + * - Honey, milk, and potion use paths call `startUsingInstantly` at + * `HoneyBottleItem.java:70-71`, `MilkBucketItem.java:48-49`, and + * `PotionItem.java:83-84`; those are drinks and are gated by action, not name. + * + * An unknown food level is allowed so a pre-spawn client never suppresses input. + * Saturation and health are intentionally not consulted. + */ +export const canEatFood = ( + name: string, + foodLevel: number | undefined, + options: HungerGateOptions = {}, +): boolean => { + if (options.isCreative || options.alwaysEdible || ALWAYS_EDIBLE_FOOD_NAMES[name]) return true + return foodLevel === undefined || foodLevel < 20 +} + +/** + * Hunger only constrains the EAT action. Drinks and other use actions must + * remain available regardless of item name or hunger level. + */ +export const canUseItemAtHunger = ( + action: UseItemAction, + name: string, + foodLevel: number | undefined, + options?: HungerGateOptions, +): boolean => action !== 'EAT' || canEatFood(name, foodLevel, options) diff --git a/src/mineflayer/useItemEffects.test.ts b/src/mineflayer/useItemEffects.test.ts new file mode 100644 index 000000000..e4e59dd92 --- /dev/null +++ b/src/mineflayer/useItemEffects.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'vitest' +import { buildUseEffectBoundaries, getCrossedUseEffectBoundaries, getUseEffectDescriptor, shouldTriggerUseEffect } from './useItemEffects' + +describe('use-item effect boundaries', () => { + test('normal food uses the vanilla duration-minus-seven boundaries', () => { + expect([...buildUseEffectBoundaries(32, false)]).toEqual([24, 20, 16, 12, 8, 4]) + }) + + test('fast food keeps the vanilla OR branch at the initial boundary', () => { + expect([...buildUseEffectBoundaries(16, true)]).toEqual([16, 12, 8, 4]) + expect([...buildUseEffectBoundaries(16, false)]).toEqual([8, 4]) + }) + + test('honey drink follows the normal 40-tick boundary set', () => { + expect([...buildUseEffectBoundaries(40, false)]).toEqual([32, 28, 24, 20, 16, 12, 8, 4]) + }) + + test('remaining zero is never a periodic boundary', () => { + expect(shouldTriggerUseEffect(0, 32, false)).toBe(false) + expect(buildUseEffectBoundaries(0, false).has(0)).toBe(false) + }) + + test('repeated elapsed values do not fire a boundary twice', () => { + const boundaries = buildUseEffectBoundaries(16, true) + const fired = new Set() + const first = getCrossedUseEffectBoundaries(0, 5, 16, boundaries, fired) + first.forEach(remaining => fired.add(remaining)) + expect(first).toEqual([16, 12]) + expect(getCrossedUseEffectBoundaries(0, 5, 16, boundaries, fired)).toEqual([]) + }) +}) + +describe('use-item effect descriptors', () => { + test('drink descriptors never request EAT particles', () => { + expect(getUseEffectDescriptor('DRINK', 'potion', 'periodic')).toEqual({ + sound: 'entity.generic.drink', + particleCount: 0, + burp: false, + }) + expect(getUseEffectDescriptor('DRINK', 'honey_bottle', 'finish')).toEqual({ + sound: 'item.honey_bottle.drink', + particleCount: 0, + burp: true, + }) + }) + + test('food descriptors distinguish periodic and finish effects', () => { + expect(getUseEffectDescriptor('EAT', 'bread', 'periodic')).toEqual({ + sound: 'entity.generic.eat', + particleCount: 5, + burp: false, + }) + expect(getUseEffectDescriptor('EAT', 'bread', 'finish')).toEqual({ + sound: 'entity.generic.eat', + particleCount: 16, + burp: true, + }) + }) + + test('non-consumable actions produce no effects', () => { + expect(getUseEffectDescriptor('BOW', 'bow', 'periodic')).toEqual({ particleCount: 0, burp: false }) + expect(getUseEffectDescriptor('SHIELD', 'shield', 'finish')).toEqual({ particleCount: 0, burp: false }) + }) +}) diff --git a/src/mineflayer/useItemEffects.ts b/src/mineflayer/useItemEffects.ts new file mode 100644 index 000000000..e8084447f --- /dev/null +++ b/src/mineflayer/useItemEffects.ts @@ -0,0 +1,87 @@ +import type { UseItemAction } from 'minecraft-renderer/src/playerState/types' + +/** + * Vanilla 1.17.1 LivingEntity.shouldTriggerItemUseEffects (tag source + * minecraft/src/net/minecraft/world/entity/LivingEntity.java:2829-2835): + * fast food uses the OR branch before the four-tick modulo check, while + * ordinary items only trigger once remaining <= duration - 7. + */ +export function shouldTriggerUseEffect (remaining: number, durationTicks: number, isFastFood: boolean): boolean { + if (remaining <= 0) return false + return (isFastFood || remaining <= durationTicks - 7) && remaining % 4 === 0 +} + +/** Build the remaining-tick boundaries in the order they are crossed. */ +export function buildUseEffectBoundaries (durationTicks: number, isFastFood: boolean): Set { + const boundaries = new Set() + for (let remaining = Math.floor(durationTicks); remaining > 0; remaining--) { + if (shouldTriggerUseEffect(remaining, durationTicks, isFastFood)) boundaries.add(remaining) + } + return boundaries +} + +/** + * Return each boundary crossed by an elapsed-tick advance, descending from + * the old remaining value. Both endpoints are included so a fast-food + * duration boundary (for example 16) fires on the first tick, never at + * session start. The caller owns the fired set and therefore controls + * idempotence without adding fields to UseItemSession. + */ +export function getCrossedUseEffectBoundaries ( + previousElapsedTicks: number, + nextElapsedTicks: number, + durationTicks: number, + boundaries: ReadonlySet, + fired: ReadonlySet +): number[] { + if (nextElapsedTicks <= previousElapsedTicks) return [] + + const previousRemaining = durationTicks - previousElapsedTicks + const nextRemaining = durationTicks - nextElapsedTicks + const crossed: number[] = [] + for (let remaining = previousRemaining; remaining >= nextRemaining; remaining--) { + if (remaining > 0 && boundaries.has(remaining) && !fired.has(remaining)) crossed.push(remaining) + } + return crossed +} + +export type UseEffectPhase = 'periodic' | 'finish' +export type UseEffectSound = 'entity.generic.eat' | 'entity.generic.drink' | 'item.honey_bottle.drink' | 'entity.player.burp' + +export interface UseEffectDescriptor { + sound?: UseEffectSound + particleCount: number + burp: boolean +} + +/** + * Vanilla 1.17.1 LivingEntity.triggerItemUseEffects (tag source + * LivingEntity.java:2897-2908) emits five item particles plus an eating sound + * for EAT, but only the drinking sound for DRINK. completeUsingItem invokes it + * with 16 (LivingEntity.java:2924-2937). Foods marks dried kelp as fast + * (Foods.java:30); Player.eat updates FoodData.eat (FoodData.java:22-31) and + * then emits PLAYER_BURP (Player.java:1969-1977). HoneyBottleItem delegates + * to that edible path before returning its bottle (HoneyBottleItem.java:23-45), + * so honey also burps on finish despite using the DRINK animation. Item + * defaults are GENERIC_EAT and GENERIC_DRINK (Item.java:348-354), while honey + * overrides drinking sound to HONEY_DRINK (HoneyBottleItem.java:60-67). + */ +export function getUseEffectDescriptor (action: UseItemAction, itemName: string, phase: UseEffectPhase): UseEffectDescriptor { + if (action === 'EAT') { + return { + sound: 'entity.generic.eat', + particleCount: phase === 'periodic' ? 5 : 16, + burp: phase === 'finish', + } + } + + if (action === 'DRINK') { + return { + sound: itemName === 'honey_bottle' ? 'item.honey_bottle.drink' : 'entity.generic.drink', + particleCount: 0, + burp: phase === 'finish' && itemName === 'honey_bottle', + } + } + + return { particleCount: 0, burp: false } +} diff --git a/src/mineflayer/useItemSession.integration.test.ts b/src/mineflayer/useItemSession.integration.test.ts new file mode 100644 index 000000000..c613c4a93 --- /dev/null +++ b/src/mineflayer/useItemSession.integration.test.ts @@ -0,0 +1,437 @@ +import { EventEmitter } from 'node:events' +import type { Bot } from 'mineflayer' +import { beforeAll, describe, expect, test, vi } from 'vitest' +import { MouseManager } from 'mineflayer-mouse/dist/mouse' +import { isItemActivatable } from 'mineflayer-mouse/dist/itemActivatable' +import { getInitialPlayerState } from 'minecraft-renderer/src/playerState/playerState' +import { shouldApplyEatTransform } from '../../../minecraft-renderer/src/three/holdingBlockEatTransform' +import type { UseItemSession } from 'minecraft-renderer/src/playerState/types' +import type { PlayerStateControllerMain as PlayerStateController } from './playerState' +import { buildUseEffectBoundaries, getCrossedUseEffectBoundaries } from './useItemEffects' +import { shouldSlowWhileUsing } from './movementWhileUsing' +import { shouldDisplayUseIndicator } from '../react/useItemIndicator' + +vi.mock('../globalState', () => ({ + gameAdditionalState: { isFlying: false, isSneaking: false }, +})) +vi.mock('../optionsStorage', () => ({ + options: { fov: 70 }, +})) +vi.mock('../mountedPlayerState', () => ({ + updateMountedBobState: vi.fn((value) => value), + updateMountedMovementState: vi.fn((value) => value), +})) +vi.mock('../cameraMovementMode', () => ({ + getCameraMovementMode: vi.fn(() => 'local-player'), +})) +vi.mock('minecraft-renderer/src/three/threeJsMethods', () => ({ + getThreeJsRendererMethods: () => { + const globals = globalThis as typeof globalThis & { __integrationRendererMethods?: unknown } + return globals.__integrationRendererMethods + }, +})) + +const customEvents = new EventEmitter() +customEvents.setMaxListeners(1000) +const integrationWindow: Record = {} +const loadedData = { + foodsArray: [ + { name: 'bread' }, + { name: 'dried_kelp' }, + { name: 'golden_apple' }, + ], +} + +// playerState.ts registers browser globals and a module-level event listener at +// import time. Install the minimal browser/session boundary before loading it; +// static import would evaluate the module before these mocks exist. +vi.stubGlobal('window', integrationWindow) +vi.stubGlobal('customEvents', customEvents) +vi.stubGlobal('loadedData', loadedData) +vi.stubGlobal('appViewer', { playerState: { reactive: getInitialPlayerState() } }) + +let PlayerStateControllerMain: typeof PlayerStateController + +beforeAll(async () => { + const playerStateModule = await import('./playerState') + PlayerStateControllerMain = playerStateModule.PlayerStateControllerMain +}) + +type FakeItem = { name: string; count: number; type: number } +type FakeBot = EventEmitter & { + _client: EventEmitter + activateItem: (offhand: boolean) => unknown + deactivateItem: () => unknown + digTime: (block: unknown) => number + stopDigging: () => unknown + supportFeature: (feature: string) => boolean + getControlState: (control: string) => boolean + inventory: { slots: Array } + heldItem: FakeItem | undefined + quickBarSlot: number + food: number + game: { gameMode: string } + version: string + entity: { id: number; position: { x: number; y: number; z: number } } | undefined +} + +type ParticleCall = { name: string; count: number } + +type Harness = { + bot: FakeBot + controller: PlayerStateController + mouse: MouseManager + particles: ParticleCall[] + sounds: string[] + startEvents: unknown[][] +} + +type EntityStatusPacket = { entityId: number; entityStatus: number } + +type ControllerUseInternals = { + cancelUseForHeldItem: (hand: 0 | 1, name?: string) => UseItemSession | undefined +} + +const item = (name: string, type = 1, count = 1): FakeItem => ({ name, type, count }) + +const isEntityStatusPacket = (value: unknown): value is EntityStatusPacket => { + if (!value || typeof value !== 'object') return false + const packet = value as Partial + return typeof packet.entityId === 'number' && typeof packet.entityStatus === 'number' +} + +const emitEntityStatus9 = (harness: Harness) => { + harness.bot.entity ??= { id: 7, position: { x: 0, y: 64, z: 0 } } + harness.bot._client.emit('entity_status', { entityId: harness.bot.entity.id, entityStatus: 9 }) +} + +const createHarness = (mainName = 'dirt', offhandName: string | undefined = 'bread', food = 19): Harness => { + const bot = new EventEmitter() as FakeBot + bot._client = new EventEmitter() + bot.activateItem = vi.fn() + bot.deactivateItem = vi.fn() + bot.digTime = vi.fn(() => 1000) + bot.stopDigging = vi.fn() + bot.supportFeature = vi.fn(() => false) + bot.getControlState = vi.fn(() => false) + bot.inventory = { slots: Array.from({ length: 46 }, () => undefined) } + bot.heldItem = item(mainName) + bot.inventory.slots[45] = offhandName ? item(offhandName, 2) : undefined + bot.quickBarSlot = 2 + bot.food = food + bot.game = { gameMode: 'survival' } + bot.version = '1.17.1' + bot.entity = undefined + vi.stubGlobal('bot', bot) + + const controller = new PlayerStateControllerMain() + controller.reactive = getInitialPlayerState() + + const particles: ParticleCall[] = [] + const sounds: string[] = [] + const globals = globalThis as typeof globalThis & { + __integrationRendererMethods?: { spawnItemParticles: (...args: unknown[]) => Promise } + } + globals.__integrationRendererMethods = { + spawnItemParticles: vi.fn(async (_x: number, _y: number, _z: number, name: string, count: number) => { + particles.push({ name, count }) + }), + } + bot.on('soundEffectHeard', (sound: unknown) => sounds.push(String(sound))) + + const startEvents: unknown[][] = [] + bot.on('startUsingItem', (...args: unknown[]) => startEvents.push(args)) + bot.on('startUsingItem', (value: unknown, _slot: unknown, isOffhand: unknown) => { + controller.startUsingItem(value as FakeItem, isOffhand === true ? 1 : 0) + }) + bot.on('stopUsingItem', (value: unknown, _slot: unknown, isOffhand: unknown) => { + controller.stopUsingItem(value as FakeItem, isOffhand === true ? 1 : 0) + }) + bot._client.on('entity_status', (value: unknown) => { + if (!isEntityStatusPacket(value)) return + if (value.entityId === bot.entity?.id && value.entityStatus === 9) controller.completeUse() + }) + + const mouse = new MouseManager(bot as unknown as Bot) + return { bot, controller, mouse, particles, sounds, startEvents } +} + +const activeSession = (harness: Harness, name: string, hand: 0 | 1 = 0): UseItemSession => { + harness.bot.entity ??= { id: 7, position: { x: 0, y: 64, z: 0 } } + const started = harness.controller.startUsingItem({ name }, hand) + const session = harness.controller.reactive.itemUseSession + expect(started).toBeDefined() + expect(session?.status).toBe('active') + return session! +} + +const advanceToEnd = (harness: Harness, session: UseItemSession) => { + for (let tick = 0; tick < session.durationTicks; tick++) harness.controller.advanceUse() + expect(session.elapsedTicks).toBe(session.durationTicks) +} + +describe('item-use input/state/UI integration', () => { + test('RMB selects edible offhand, starts hand 1, and gates only the offhand render', () => { + const harness = createHarness('dirt', 'bread') + const { bot, controller, mouse, startEvents } = harness + + // MouseManager's updatePlaceInteract is the actual RMB path. Keep the + // cursor empty so the activatable-hand selection is the only branch. + mouse.buttons[2] = true + mouse.update() + bot.entity = { id: 7, position: { x: 0, y: 64, z: 0 } } + + const session = controller.reactive.itemUseSession + expect(bot.activateItem).toHaveBeenCalledWith(true) + expect(startEvents[0]?.[1]).toBe(45) + expect(startEvents[0]?.[2]).toBe(true) + expect(session?.itemSnapshot.name).toBe('bread') + expect(session?.hand).toBe(1) + expect(shouldDisplayUseIndicator(session)).toBe(true) + expect(shouldApplyEatTransform(session, 0)).toBe(false) + expect(shouldApplyEatTransform(session, 1)).toBe(true) + expect(shouldSlowWhileUsing(session, false)).toBe(true) + }) + + test('the 1.17.1 activatable table includes foods and drinks but not placeable blocks', () => { + for (const name of ['bread', 'dried_kelp', 'golden_apple', 'honey_bottle', 'milk_bucket', 'potion']) { + expect(isItemActivatable('1.17.1', { name })).toBe(true) + } + expect(isItemActivatable('1.17.1', { name: 'dirt' })).toBe(false) + }) + + test.each([ + { name: 'bread', action: 'EAT', duration: 32, periodic: 6 }, + { name: 'dried_kelp', action: 'EAT', duration: 16, periodic: 4 }, + { name: 'honey_bottle', action: 'DRINK', duration: 40, periodic: 8 }, + ] as const)('advances $name for $duration ticks and emits each periodic boundary once', ({ name, action, duration, periodic }) => { + const harness = createHarness('dirt', undefined) + const session = activeSession(harness, name) + + expect(session.action).toBe(action) + expect(session.durationTicks).toBe(duration) + advanceToEnd(harness, session) + if (action === 'EAT') { + expect(harness.particles).toHaveLength(periodic) + expect(harness.particles.every(particle => particle.count === 5)).toBe(true) + } else { + expect(harness.particles).toHaveLength(0) + } + expect([...buildUseEffectBoundaries(duration, name === 'dried_kelp')]).toHaveLength(periodic) + }) + + test('the local player plays eat/drink sounds because the server excludes the source from sound_effect', () => { + const eat = createHarness('dirt', undefined) + advanceToEnd(eat, activeSession(eat, 'bread')) + expect(eat.sounds.filter(sound => sound === 'entity.generic.eat')).toHaveLength(6) + + emitEntityStatus9(eat) + expect(eat.sounds.filter(sound => sound === 'entity.generic.eat')).toHaveLength(7) + expect(eat.sounds.filter(sound => sound === 'entity.player.burp')).toHaveLength(1) + + const drink = createHarness('dirt', undefined) + advanceToEnd(drink, activeSession(drink, 'honey_bottle')) + expect(drink.sounds.filter(sound => sound === 'item.honey_bottle.drink')).toHaveLength(8) + expect(drink.particles).toHaveLength(0) + }) + + test('full hunger gates bread but still starts a golden apple', () => { + const harness = createHarness('dirt', undefined, 20) + const { controller } = harness + + expect(controller.beginUse({ name: 'bread' }, 0)).toBeUndefined() + const goldenApple = controller.beginUse({ name: 'golden_apple' }, 0) + expect(goldenApple?.itemSnapshot.name).toBe('golden_apple') + expect(goldenApple?.action).toBe('EAT') + expect(goldenApple?.hand).toBe(0) + }) + + test('early release cancels and never emits a finish effect', () => { + const harness = createHarness('dirt', undefined) + const { controller } = harness + const session = activeSession(harness, 'bread') + + controller.advanceUse() + controller.advanceUse() + controller.stopUsingItem({ name: 'bread' }, 0) + expect(session.status).toBe('cancelled') + emitEntityStatus9(harness) + expect(session.status).toBe('cancelled') + expect(harness.particles).toHaveLength(0) + }) + + test.each([ + ['stop then event-9', true], + ['event-9 then stop', false], + ] as const)('stop and event-9 in either order finish exactly once (%s)', (_label, stopFirst) => { + const harness = createHarness('dirt', undefined) + const { controller, mouse } = harness + const session = activeSession(harness, 'bread') + advanceToEnd(harness, session) + + if (stopFirst) { + controller.stopUsingItem({ name: 'bread' }, 0) + emitEntityStatus9(harness) + } else { + emitEntityStatus9(harness) + controller.stopUsingItem({ name: 'bread' }, 0) + } + expect(session.status).toBe('completed') + expect(harness.particles.filter(particle => particle.count === 5)).toHaveLength(6) + expect(harness.particles.filter(particle => particle.count === 16)).toHaveLength(1) + + emitEntityStatus9(harness) + const stopMouseUsingItem = (mouse as unknown as { stopUsingItem: () => void }).stopUsingItem + stopMouseUsingItem.call(mouse) + expect(harness.particles.filter(particle => particle.count === 16)).toHaveLength(1) + }) + + test('event-9 while elapsed is still below duration completes the current eat', () => { + const harness = createHarness('dirt', undefined) + const { controller } = harness + const session = activeSession(harness, 'bread') + + controller.advanceUse() + controller.advanceUse() + expect(session.elapsedTicks).toBeLessThan(session.durationTicks) + expect(session.status).toBe('active') + emitEntityStatus9(harness) + expect(session.status).toBe('completed') + expect(harness.particles).toEqual([{ name: 'bread', count: 16 }]) + }) + + test('event-9 after cancel while the cancelled session is still current is a no-op', () => { + const harness = createHarness('dirt', undefined) + const { controller } = harness + const session = activeSession(harness, 'bread') + + controller.advanceUse() + controller.stopUsingItem({ name: 'bread' }, 0) + emitEntityStatus9(harness) + emitEntityStatus9(harness) + expect(session.status).toBe('cancelled') + expect(harness.particles).toHaveLength(0) + }) + + test('beginUse during awaitingCompletion returns the same session and does not start B', () => { + const harness = createHarness('dirt', undefined) + const { controller } = harness + const session = activeSession(harness, 'bread') + advanceToEnd(harness, session) + expect(session.status).toBe('awaitingCompletion') + expect(shouldDisplayUseIndicator(session)).toBe(true) + + const again = controller.beginUse({ name: 'bread' }, 0) + expect(again).toBe(session) + expect(again?.id).toBe(session.id) + expect(controller.reactive.itemUseSession).toBe(session) + expect(session.status).toBe('awaitingCompletion') + }) + + test('duplicate event-9 after completed is a no-op and finish particles fire once', () => { + const harness = createHarness('dirt', undefined) + const session = activeSession(harness, 'bread') + advanceToEnd(harness, session) + emitEntityStatus9(harness) + expect(session.status).toBe('completed') + expect(harness.particles.filter(particle => particle.count === 16)).toHaveLength(1) + + emitEntityStatus9(harness) + expect(session.status).toBe('completed') + expect(harness.particles.filter(particle => particle.count === 16)).toHaveLength(1) + }) + + test('stop at predicted end then event-9 on the same session completes once', () => { + const harness = createHarness('dirt', undefined) + const { controller } = harness + const session = activeSession(harness, 'bread') + advanceToEnd(harness, session) + controller.stopUsingItem({ name: 'bread' }, 0) + expect(session.status).toBe('awaitingCompletion') + expect(shouldDisplayUseIndicator(session)).toBe(true) + + emitEntityStatus9(harness) + expect(session.status).toBe('completed') + expect(harness.particles.filter(particle => particle.count === 16)).toHaveLength(1) + }) + + test('consecutive eats receive a new id and reset elapsed ticks', () => { + const harness = createHarness('dirt', undefined) + const first = activeSession(harness, 'bread') + advanceToEnd(harness, first) + emitEntityStatus9(harness) + expect(first.status).toBe('completed') + + const second = activeSession(harness, 'bread') + expect(second.id).toBe(first.id + 1) + expect(second.elapsedTicks).toBe(0) + expect(second.status).toBe('active') + }) + + test('count changes retain the session, but a real slot switch cancels it', () => { + const harness = createHarness('bread', undefined) + const { bot, controller } = harness + const session = activeSession(harness, 'bread') + const cancelForHeldItem = (controller as unknown as ControllerUseInternals).cancelUseForHeldItem.bind(controller) + + bot.heldItem = item('bread', 1, 0) + cancelForHeldItem(0, 'bread') + expect(session.status).toBe('active') + + bot.quickBarSlot = 3 + + bot.heldItem = item('dirt') + cancelForHeldItem(0, 'dirt') + expect(session.status).toBe('cancelled') + expect(shouldDisplayUseIndicator(session)).toBe(false) + expect(shouldApplyEatTransform(session, 0)).toBe(false) + expect(shouldSlowWhileUsing(session, false)).toBe(false) + }) + test('depleted offhand stack retains snapshot and hand through awaiting completion', () => { + const harness = createHarness('dirt', 'bread') + const { bot, controller } = harness + const session = activeSession(harness, 'bread', 1) + advanceToEnd(harness, session) + + bot.inventory.slots[45] = undefined + const cancelForHeldItem = (controller as unknown as ControllerUseInternals).cancelUseForHeldItem.bind(controller) + cancelForHeldItem(1, undefined) + expect(session.status).toBe('awaitingCompletion') + expect(session.itemSnapshot.name).toBe('bread') + expect(session.hand).toBe(1) + expect(shouldDisplayUseIndicator(session)).toBe(true) + expect(shouldApplyEatTransform(session, 1)).toBe(false) + + emitEntityStatus9(harness) + expect(session.status).toBe('completed') + expect(session.itemSnapshot.name).toBe('bread') + expect(session.hand).toBe(1) + expect(shouldDisplayUseIndicator(session)).toBe(false) + expect(shouldApplyEatTransform(session, 1)).toBe(false) + }) + + test('death/reset clears the session and all downstream predicates', () => { + const harness = createHarness('dirt', undefined) + const { bot, controller } = harness + const session = activeSession(harness, 'bread') + bot.on('death', () => controller.resetUseSession()) + bot.emit('death') + + expect(controller.reactive.itemUseSession).toBeUndefined() + expect(shouldDisplayUseIndicator(controller.reactive.itemUseSession)).toBe(false) + expect(shouldApplyEatTransform(controller.reactive.itemUseSession, session.hand)).toBe(false) + expect(shouldSlowWhileUsing(controller.reactive.itemUseSession, false)).toBe(false) + }) + + test('a multi-tick catch-up crosses each boundary once and is idempotent', () => { + const harness = createHarness('dirt', undefined) + const session = activeSession(harness, 'bread') + const boundaries = buildUseEffectBoundaries(session.durationTicks, false) + const fired = new Set() + + const crossed = getCrossedUseEffectBoundaries(0, session.durationTicks, session.durationTicks, boundaries, fired) + crossed.forEach(value => fired.add(value)) + expect(crossed).toHaveLength(6) + expect(getCrossedUseEffectBoundaries(0, session.durationTicks, session.durationTicks, boundaries, fired)).toEqual([]) + }) +}) diff --git a/src/react/Crosshair.css b/src/react/Crosshair.css index c49740175..e840f1797 100644 --- a/src/react/Crosshair.css +++ b/src/react/Crosshair.css @@ -24,3 +24,7 @@ background: lightgray; pointer-events: none; } + +.crosshair-indicator-pending { + opacity: 0.55; +} diff --git a/src/react/Crosshair.tsx b/src/react/Crosshair.tsx index 542545f76..eb04d6f63 100644 --- a/src/react/Crosshair.tsx +++ b/src/react/Crosshair.tsx @@ -1,70 +1,24 @@ -import { useEffect, useState } from 'react' +import { useSnapshot } from 'valtio' import './Crosshair.css' -import { proxy, useSnapshot } from 'valtio' import SharedHudVars from './SharedHudVars' - -// todo move to mineflayer -export const itemBeingUsed = proxy({ - name: null as string | null, - hand: 0 as 0 | 1 -}) +import { useUseIndicatorVisual } from './useItemIndicator' export default () => { - const { name: usingItem, hand } = useSnapshot(itemBeingUsed) - - const [displayIndicator, setDisplayIndicator] = useState(false) - const [indicatorProgress, setIndicatorProgress] = useState(0) - const [alternativeIndicator, setAlternativeIndicator] = useState(false) - const boxMaxTimeMs = 1000 - // todo add sword indicator + const { itemUseSession } = useSnapshot(appViewer.playerState.reactive) + const session = itemUseSession + const action = session?.itemSnapshot.action + const { display: displayIndicator, progress: indicatorProgress, pending } = useUseIndicatorVisual(session) + const alternativeIndicator = displayIndicator && action === 'SHIELD' const indicatorSize = 20 - - useEffect(() => { - bot.on('heldItemChanged' as any, () => { - const displayBar = (item: import('prismarine-item').Item | null) => { - const itemName = item?.name - if (!itemName) return - return loadedData.foodsArray.map((food) => food.name).includes(itemName) || itemName === 'bow' || itemName === 'shield' || itemName === 'crossbow' - } - setDisplayIndicator(displayBar(bot.heldItem) || displayBar(bot.inventory.slots[45]) || false) - }) - }, []) - - useEffect(() => { - setAlternativeIndicator(usingItem === 'shield') - if (!usingItem) return - const startTime = Date.now() - let maxTime = 0 - if (usingItem === 'bow' || usingItem === 'crossbow') { - maxTime = 1000 - } - const isFood = loadedData.foodsArray.some((food) => food.name === usingItem) - if (isFood) { - maxTime = 32 * 50 - } - if (!maxTime) return - - const id = setInterval(() => { - const progress = (Date.now() - startTime) / boxMaxTimeMs - if (progress >= 1) { - clearInterval(id) - } else { - setIndicatorProgress(progress) - } - }, 1000 / 60) - return () => { - setIndicatorProgress(0) - clearInterval(id) - } - }, [usingItem]) + const indicatorColor = pending ? '#ffd54f' : 'white' return
{displayIndicator &&
} diff --git a/src/react/useItemIndicator.test.ts b/src/react/useItemIndicator.test.ts new file mode 100644 index 000000000..e02d22122 --- /dev/null +++ b/src/react/useItemIndicator.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'vitest' +import { computeUseIndicatorProgress, shouldDisplayUseIndicator, type UseIndicatorClock } from './useItemIndicator' +import type { UseItemSession } from 'minecraft-renderer/src/playerState/types' + +const clock = (ticks?: number, changeMs = 0): UseIndicatorClock => ({ + lastElapsedTicks: ticks, + lastElapsedChangeMs: changeMs, +}) + +const session = (overrides: Partial = {}): UseItemSession => ({ + id: 1, + itemSnapshot: { + name: 'bread', + durationTicks: 32, + action: 'EAT', + particleEffect: 'food', + }, + hand: 0, + action: 'EAT', + durationTicks: 32, + elapsedTicks: 8, + status: 'active', + ...overrides, +}) + +describe('shouldDisplayUseIndicator', () => { + test('stays visible while awaiting completion', () => { + expect(shouldDisplayUseIndicator(session({ status: 'awaitingCompletion', elapsedTicks: 32 }))).toBe(true) + expect(shouldDisplayUseIndicator(session({ status: 'completed' }))).toBe(false) + expect(shouldDisplayUseIndicator(session({ status: 'cancelled' }))).toBe(false) + }) +}) + +describe('computeUseIndicatorProgress', () => { + test('interpolates elapsed plus a 50ms partial toward the next tick', () => { + const started = computeUseIndicatorProgress(session({ elapsedTicks: 8 }), 1000, clock()) + expect(started.progress).toBe(8 / 32) + expect(started.pending).toBe(false) + + const halfway = computeUseIndicatorProgress(session({ elapsedTicks: 8 }), 1025, started.clock) + expect(halfway.progress).toBeCloseTo((8 + 0.5) / 32) + expect(halfway.pending).toBe(false) + + const fullPartial = computeUseIndicatorProgress(session({ elapsedTicks: 8 }), 1050, started.clock) + expect(fullPartial.progress).toBeCloseTo(9 / 32) + }) + + test('clamps awaiting completion at 1 and marks it pending', () => { + const visual = computeUseIndicatorProgress( + session({ status: 'awaitingCompletion', elapsedTicks: 32 }), + 2000, + clock(31, 1950) + ) + expect(visual.progress).toBe(1) + expect(visual.pending).toBe(true) + }) +}) diff --git a/src/react/useItemIndicator.ts b/src/react/useItemIndicator.ts new file mode 100644 index 000000000..28a331757 --- /dev/null +++ b/src/react/useItemIndicator.ts @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState } from 'react' +import type { UseItemSession } from 'minecraft-renderer/src/playerState/types' + +export const USE_INDICATOR_TICK_MS = 50 + +export const shouldDisplayUseIndicator = ( + session: Pick | null | undefined +): boolean => { + const action = session?.itemSnapshot.action + return !!session + && (session.status === 'active' || session.status === 'awaitingCompletion') + && (action === 'EAT' || action === 'DRINK' || action === 'BOW' || action === 'CROSSBOW' || action === 'SHIELD') +} + +export type UseIndicatorClock = { + lastElapsedTicks: number | undefined + lastElapsedChangeMs: number +} + +export function computeUseIndicatorProgress ( + session: Pick | null | undefined, + nowMs: number, + clock: UseIndicatorClock +): { progress: number; pending: boolean; clock: UseIndicatorClock } { + if (!session || session.durationTicks <= 0) { + return { progress: 0, pending: false, clock } + } + + const pending = session.status === 'awaitingCompletion' + if (pending) { + return { progress: 1, pending: true, clock } + } + + let { lastElapsedTicks, lastElapsedChangeMs } = clock + if (lastElapsedTicks !== session.elapsedTicks) { + lastElapsedTicks = session.elapsedTicks + lastElapsedChangeMs = nowMs + } + const partial = Math.min(Math.max((nowMs - lastElapsedChangeMs) / USE_INDICATOR_TICK_MS, 0), 1) + const progress = Math.min((session.elapsedTicks + partial) / session.durationTicks, 1) + return { + progress, + pending: false, + clock: { lastElapsedTicks, lastElapsedChangeMs }, + } +} + +export function useUseIndicatorVisual ( + session: Pick | null | undefined +) { + const display = shouldDisplayUseIndicator(session) + const clockRef = useRef({ + lastElapsedTicks: undefined, + lastElapsedChangeMs: 0, + }) + const [nowMs, setNowMs] = useState(() => (typeof performance === 'undefined' ? 0 : performance.now())) + + useEffect(() => { + if (!display || session?.status === 'awaitingCompletion') return undefined + let frame = 0 + const loop = (time: number) => { + setNowMs(time) + frame = requestAnimationFrame(loop) + } + frame = requestAnimationFrame(loop) + return () => cancelAnimationFrame(frame) + }, [display, session?.status, session?.id]) + + const visual = computeUseIndicatorProgress(session, nowMs, clockRef.current) + + useEffect(() => { + clockRef.current = visual.clock + }, [visual.clock]) + + return { + display, + progress: visual.progress, + pending: visual.pending, + } +}