UNPKG

@jeremyckahn/farmhand

Version:
865 lines (760 loc) 27 kB
import { useState, useMemo, useEffect, useRef, useCallback } from 'react' import window from 'global/window.js' import debounce from 'lodash.debounce' import { v4 as uuid } from 'uuid' import * as reducers from '../../game-logic/reducers/index.js' import eventHandlers from '../../handlers/ui-events.js' import { DEFAULT_ROOM, INITIAL_STORAGE_LIMIT, STAGE_TITLE_MAP, STANDARD_LOAN_AMOUNT, } from '../../constants.js' import { scarecrow } from '../../data/items.js' import { dialogView, fieldMode, stageFocusType, toolLevel, toolType, } from '../../enums.js' import { getData, postData } from '../../fetch-utils.js' import { PROGRESS_SAVED_MESSAGE } from '../../strings.js' import { LOAN_INCREASED } from '../../templates.js' import { createNewField } from '../../utils/createNewField.js' import { createNewForest } from '../../utils/createNewForest.js' import { doesMenuObstructStage } from '../../utils/doesMenuObstructStage.js' import { generateCow } from '../../utils/generateCow.js' import { getAvailableShopInventory } from '../../utils/getAvailableShopInventory.js' import { getLevelEntitlements } from '../../utils/getLevelEntitlements.js' import { getPeerMetadata } from '../../utils/getPeerMetadata.js' import { getValidatedStageFocusFromHash } from '../../utils/getValidatedStageFocusFromHash.js' import { getViewList } from '../../utils/getViewList.js' import { removeHashQueryParam, setHashQueryParam, VIEW_QUERY_PARAM_NAME, } from '../../utils/hashQueryParams.js' import { levelAchieved } from '../../utils/levelAchieved.js' import { moneyTotal } from '../../utils/moneyTotal.js' import { noop } from '../../utils/noop.js' import { nullArray } from '../../utils/nullArray.js' import { reduceByPersistedKeys } from '../../utils/reduceByPersistedKeys.js' import { sleep } from '../../utils/sleep.js' import { transformStateDataForImport } from '../../utils/transformStateDataForImport.js' import { BoundHandlers } from './Farmhand.context.js' import { FarmhandProps } from './FarmhandReducers.js' import { FarmhandService } from './FarmhandService.js' import { getInventoryQuantities } from './helpers/getInventoryQuantities.js' import { useFarmhandNavigation } from './hooks/useFarmhandNavigation.js' import { useFarmhandNetwork } from './hooks/useFarmhandNetwork.js' import { useFarmhandDayAdvancement, NextDayStateRecord, } from './hooks/useFarmhandDayAdvancement.js' import { useFarmhandNotifications } from './hooks/useFarmhandNotifications.js' import { useFarmhandReducers } from './useFarmhandReducers.js' import { usePrevious } from './usePrevious.js' const { CLEANUP, HARVEST, MINE, WATER } = fieldMode export const useFarmhand = (props: FarmhandProps) => { // Extract props properly const { features: propsFeatures, match: { path = '', params: { room: paramRoom } = {} } = {}, } = props const createInitialState = useCallback((): farmhand.state => { return { activePlayers: null, allowCustomPeerCowNames: false, cellarInventory: [], currentDialogView: dialogView.NONE as farmhand.dialogView, completedAchievements: {}, cowForSale: generateCow() as farmhand.cow, cowBreedingPen: { cowId1: null, cowId2: null, daysUntilBirth: -1 }, cowColorsPurchased: {}, cowIdOfferedForTrade: '', cowInventory: [], cowsSold: {}, cowsTraded: 0, cowTradeTimeoutId: -1, cropsHarvested: {}, dayCount: 0, experience: 0, farmName: 'Unnamed', field: createNewField(), fieldMode: fieldMode.OBSERVE as farmhand.fieldMode, forest: createNewForest(), treesChopped: 0, treeFruitsHarvested: {}, mulchApplied: {}, getCowAccept: noop, getCowReject: noop, getCowTradeRequest: noop, getPeerMetadata: noop, hasBooted: false, hasProducedRainbowFertilizer: false, heartbeatTimeoutId: null, historicalDailyLosses: [], historicalDailyRevenue: [], historicalValueAdjustments: [], hoveredPlotRangeSize: 0, playerId: uuid(), inventory: [{ id: scarecrow.id, quantity: 1 }], inventoryLimit: INITIAL_STORAGE_LIMIT, isAwaitingCowTradeRequest: false, isAwaitingNetworkRequest: false, isCombineEnabled: false, isMenuOpen: !doesMenuObstructStage(), itemsSold: {}, cellarItemsSold: {}, isChatOpen: false, isDialogViewOpen: false, isOnline: path.startsWith('/online') ?? false, isWaitingForDayToCompleteIncrementing: false, learnedRecipes: {}, loanBalance: STANDARD_LOAN_AMOUNT, loansTakenOut: 1, money: STANDARD_LOAN_AMOUNT, latestNotification: null, newDayNotifications: [], notificationLog: [], peers: {}, peerRoom: null, pendingPeerMessages: [], latestPeerMessages: [], sendPeerMetadata: null, selectedCowId: '', selectedForestItemId: '', selectedItemId: '', priceCrashes: {}, priceSurges: {}, profitabilityStreak: 0, recipesMade: {}, record7dayProfitAverage: 0, recordProfitabilityStreak: 0, recordSingleDayProfit: 0, revenue: 0, redirect: '', room: decodeURIComponent(props.match?.params?.room || DEFAULT_ROOM), sendCowAccept: noop, sendCowReject: noop, purchasedCombine: 0, purchasedComposter: 0, purchasedCowPen: 0, purchasedCellar: 0, purchasedField: 0, purchasedForest: 0, purchasedSmelter: 0, purchasedWoodChipper: 0, sendCowTradeRequest: noop, showHomeScreen: true, showNotifications: true, stageFocus: stageFocusType.HOME as farmhand.stageFocusType, todaysNotifications: [], todaysLosses: 0, todaysPurchases: {}, todaysRevenue: 0, todaysStartingInventory: {}, toolLevels: { [toolType.AXE as farmhand.toolType]: toolLevel.UNAVAILABLE as farmhand.toolLevel, [toolType.HOE as farmhand.toolType]: toolLevel.DEFAULT as farmhand.toolLevel, [toolType.PICKER_POLE as farmhand.toolType]: toolLevel.UNAVAILABLE as farmhand.toolLevel, [toolType.SCYTHE as farmhand.toolType]: toolLevel.DEFAULT as farmhand.toolLevel, [toolType.SHOVEL as farmhand.toolType]: toolLevel.UNAVAILABLE as farmhand.toolLevel, [toolType.WATERING_CAN as farmhand.toolType]: toolLevel.DEFAULT as farmhand.toolLevel, } as Record<farmhand.toolType, farmhand.toolLevel>, useAlternateEndDayButtonPosition: false, valueAdjustments: {}, version: import.meta.env?.VITE_FARMHAND_PACKAGE_VERSION ?? '', } }, [path, props.match?.params?.room]) const [state, setState] = useState<farmhand.state>(createInitialState) const stateRef = useRef<farmhand.state>(state) stateRef.current = state // Falls back to the current room in state when the URL doesn't specify one, // mirroring the legacy class's componentDidUpdate default of // `params: { room: newRoom = room } = this.props.match`. const newRoom = paramRoom !== undefined ? paramRoom : state.room const nextDayStateRef = useRef<NextDayStateRecord | null>(null) const hasStartedBootRef = useRef(false) const boundReducers = useFarmhandReducers(setState) const boundReducersRef = useRef(boundReducers) boundReducersRef.current = boundReducers const prevState = usePrevious(state) const viewTitle = STAGE_TITLE_MAP[state.stageFocus as keyof typeof STAGE_TITLE_MAP] const fieldToolInventory = FarmhandService.getFieldToolInventory( state.inventory ) const playerInventory = FarmhandService.computePlayerInventory( state.inventory, state.valueAdjustments ) const plantableCropInventory = FarmhandService.getPlantableCropInventory( state.inventory ) const plantableTreeInventory = FarmhandService.getPlantableTreeInventory( state.inventory ) const mulchInventory = FarmhandService.getMulchInventory(state.inventory) const levelEntitlements = useMemo( () => getLevelEntitlements(levelAchieved(state.experience)), [state.experience] ) const shopInventory = useMemo( () => getAvailableShopInventory(levelEntitlements), [levelEntitlements] ) const isForestUnlocked = levelEntitlements.stageFocusType[stageFocusType.FOREST] const isChatAvailable = state.isOnline && state.room !== DEFAULT_ROOM const viewList = useMemo( () => getViewList({ isForestUnlocked, purchasedCellar: state.purchasedCellar, purchasedCowPen: state.purchasedCowPen, showHomeScreen: state.showHomeScreen, }), [ state.showHomeScreen, isForestUnlocked, state.purchasedCowPen, state.purchasedCellar, ] ) const peerMetadata = useMemo(() => getPeerMetadata(state), [state]) const isInputBlocked = state.isAwaitingNetworkRequest || state.isAwaitingCowTradeRequest || state.isWaitingForDayToCompleteIncrementing const instanceProxyRef = useRef<any>(null) // Call sub-hooks const { openDialogView, closeDialogView, focusNextView, focusPreviousView, } = useFarmhandNavigation(setState, viewList) // Mirrors the current view into the URL hash's `view` query param, using // pushState (not replaceState) so each in-app view change is a real, // back/forward-navigable browser history entry - otherwise every // navigation just overwrites the one entry the page loaded with, and a // single "back" press takes the player out of the app entirely. The // `tab` param (a tabbed screen's active tab, see useTabQueryParam) is // left alone on the very first run (so a reload can still restore it), // but cleared on every subsequent view change, since it belongs to // whichever screen was just left. const isInitialStageFocusSyncRef = useRef(true) // Set by the popstate handler below immediately before it updates // stageFocus, so this effect's next run corrects/confirms the hash in // place (replace) instead of pushing a new entry for a change that was // itself a back/forward navigation. const isPopstateStageFocusSyncRef = useRef(false) useEffect(() => { // Boot (see the effect below) is responsible for the URL's initial // `view` value: it validates it against what's actually unlocked // before applying it. Mirroring state.stageFocus here before that // finishes would immediately overwrite an as-yet-unread bookmarked/ // shared URL with the pre-boot default. if (!state.hasBooted) return if (isPopstateStageFocusSyncRef.current) { isPopstateStageFocusSyncRef.current = false setHashQueryParam(VIEW_QUERY_PARAM_NAME, state.stageFocus) return } if (isInitialStageFocusSyncRef.current) { isInitialStageFocusSyncRef.current = false setHashQueryParam(VIEW_QUERY_PARAM_NAME, state.stageFocus) return } setHashQueryParam(VIEW_QUERY_PARAM_NAME, state.stageFocus, 'push') removeHashQueryParam('tab') }, [state.stageFocus, state.hasBooted]) // Reacts to the browser's back/forward buttons, since pushState above // now creates real history entries to navigate between. Re-validates // the target view against current unlocks (the same guard boot uses) // rather than trusting the history entry outright, since it could be // stale relative to what's since become unlocked - or not - in this // save. useEffect(() => { const handlePopState = () => { const validatedStageFocus = getValidatedStageFocusFromHash(stateRef.current) ?? stageFocusType.HOME if (validatedStageFocus === stateRef.current.stageFocus) return isPopstateStageFocusSyncRef.current = true setState(previous => ({ ...previous, stageFocus: validatedStageFocus })) } window.addEventListener('popstate', handlePopState) return () => window.removeEventListener('popstate', handlePopState) }, []) const { syncToRoom, scheduleHeartbeat, tradeForPeerCow, handleCowTradeTimeout, messagePeers, } = useFarmhandNetwork( state, setState, boundReducersRef, instanceProxyRef, prevState, newRoom, path, peerMetadata ) const { clearPersistedData, persistState, updateServerForNextDay, incrementDay, } = useFarmhandDayAdvancement( stateRef, setState, props, boundReducersRef, nextDayStateRef ) const { showInventoryFullNotifications, showRecipeLearnedNotifications, } = useFarmhandNotifications(state, boundReducersRef) // Instance proxy to mimic the legacy class "this" so ui-events.tsx can run unmodified // eslint-disable-next-line react-hooks/exhaustive-deps const instanceProxy = useMemo( () => ({ state, props, viewTitle, fieldToolInventory, mulchInventory, playerInventory, plantableCropInventory, plantableTreeInventory, viewList, levelEntitlements, shopInventory, peerMetadata, isInputBlocked, isChatAvailable, isForestUnlocked, setState: (updater: any, callback?: () => void) => { setState(previous => { const next = typeof updater === 'function' ? updater(previous) : updater if (next === null || next === undefined) return previous const newState = { ...previous, ...next } if (callback) setTimeout(callback, 0) return newState }) }, ...boundReducersRef.current, purchaseItem: (item: any, quantity: number) => boundReducersRef.current.purchaseItem(item, quantity), sellItem: (item: any, quantity: number) => boundReducersRef.current.sellItem(item, quantity), showNotification: (msg: string, sev?: string, onClick?: () => void) => boundReducersRef.current.showNotification(msg, sev, onClick), prependPendingPeerMessage: (msg: string, sev?: string) => boundReducersRef.current.prependPendingPeerMessage(msg, sev), forRange: ( fn: any, limit: number, arg1: number, arg2: number, arg3?: any ) => boundReducersRef.current.forRange(fn, limit, arg1, arg2, arg3), createInitialState, getData, postData, tradeForPeerCow, handleCowTradeTimeout, clearPersistedData, syncToRoom, scheduleHeartbeat, showInventoryFullNotifications, showRecipeLearnedNotifications, persistState: (overrides = {}) => persistState(instanceProxyRef.current?.state ?? state, overrides), updateServerForNextDay, incrementDay, openDialogView, closeDialogView, focusNextView, focusPreviousView, messagePeers, }), [ state, props, viewTitle, fieldToolInventory, mulchInventory, playerInventory, plantableCropInventory, plantableTreeInventory, viewList, levelEntitlements, shopInventory, peerMetadata, isInputBlocked, isChatAvailable, isForestUnlocked, tradeForPeerCow, handleCowTradeTimeout, clearPersistedData, syncToRoom, scheduleHeartbeat, showInventoryFullNotifications, showRecipeLearnedNotifications, persistState, updateServerForNextDay, incrementDay, openDialogView, closeDialogView, focusNextView, focusPreviousView, messagePeers, createInitialState, ] ) instanceProxyRef.current = instanceProxy const handlers = useMemo(() => { const bound: any = { debounced: {} } Object.keys(eventHandlers).forEach(methodStr => { const method = methodStr as keyof typeof eventHandlers bound[method] = (...args: any[]) => (eventHandlers[method] as any).apply(instanceProxyRef.current, args) bound.debounced[method] = debounce(bound[method], 50) }) return bound as BoundHandlers<typeof eventHandlers> & { debounced: BoundHandlers<typeof eventHandlers> } }, []) const keyMap = useMemo(() => { const map: Record<string, string> = { incrementDay: 'shift+c', nextView: 'right', openAccounting: 'b', openAchievements: 'a', openLog: 'l', openPriceEvents: 'e', openStats: 's', openSettings: ',', openKeybindings: 'shift+?', previousView: 'left', toggleMenu: 'm', } nullArray(9).forEach((_: null, i: number) => { map[`numberKey${i + 1}`] = String(i + 1) }) if (import.meta.env?.MODE === 'development') { Object.assign(map, { clearPersistedData: 'shift+d', waterAllPlots: 'w' }) } return map }, []) const keyHandlers = useMemo(() => { const map: Record<string, () => void> = { incrementDay: () => incrementDay(), nextView: focusNextView, openAccounting: () => openDialogView(dialogView.ACCOUNTING), openAchievements: () => openDialogView(dialogView.ACHIEVEMENTS), openLog: () => openDialogView(dialogView.FARMERS_LOG), openPriceEvents: () => openDialogView(dialogView.PRICE_EVENTS), openStats: () => openDialogView(dialogView.STATS), openSettings: () => openDialogView(dialogView.SETTINGS), openKeybindings: () => openDialogView(dialogView.KEYBINDINGS), previousView: focusPreviousView, selectHoe: () => handlers.handleFieldModeSelect(CLEANUP), selectScythe: () => handlers.handleFieldModeSelect(HARVEST), selectWateringCan: () => handlers.handleFieldModeSelect(WATER), selectShovel: () => { if (state.toolLevels[toolType.SHOVEL] !== toolLevel.UNAVAILABLE) { handlers.handleFieldModeSelect(MINE) } }, toggleMenu: () => handlers.handleMenuToggle(), } nullArray(9).forEach((_: null, i: number) => { map[`numberKey${i + 1}`] = () => { const viewName = viewList[i] if (typeof viewName === 'string') { setState(previous => ({ ...previous, stageFocus: stageFocusType[viewName as keyof typeof stageFocusType], })) } } }) if (import.meta.env?.MODE === 'development') { Object.assign(map, { clearPersistedData: () => clearPersistedData(), waterAllPlots: () => boundReducersRef.current.waterAllPlots(), }) } return map }, [ incrementDay, focusNextView, openDialogView, focusPreviousView, handlers, state.toolLevels, viewList, clearPersistedData, ]) useEffect(() => { if (hasStartedBootRef.current) return hasStartedBootRef.current = true Object.defineProperty(window, 'farmhand', { get: () => instanceProxyRef.current, configurable: true, }) let isMounted = true void (async () => { const persistedState = await props.localforage?.getItem('state') if (!isMounted) return if (persistedState) { const sanitizedState = transformStateDataForImport({ ...createInitialState(), ...persistedState, }) const { isCombineEnabled, newDayNotifications } = sanitizedState const restoredStageFocus = getValidatedStageFocusFromHash( sanitizedState ) setState(previous => ({ ...previous, ...sanitizedState, ...(restoredStageFocus && { stageFocus: restoredStageFocus }), newDayNotifications: [], hasBooted: true, })) newDayNotifications.forEach( ({ message, severity }: farmhand.notification) => { setTimeout( () => boundReducersRef.current.showNotification(message, severity), 0 ) } ) // NOTE: Intentional behavior change from the legacy class component: // this used to run once per queued newDayNotifications entry (and // not at all when there were none). It now runs unconditionally, // exactly once, whenever isCombineEnabled is true on boot. if (isCombineEnabled) { boundReducersRef.current.forRange( reducers.harvestPlot, Infinity, 0, 0 ) } } else { await incrementDay(true) setState(previous => ({ ...previous, historicalValueAdjustments: [] })) boundReducersRef.current.showNotification( LOAN_INCREASED('', STANDARD_LOAN_AMOUNT), 'info' ) // Reads the unlock-relevant fields off the current (fresh-game) // state rather than calling createInitialState() again - that // would re-run generateCow()/uuid(), consuming extra draws from // the seeded RNG sequence that other code (and e2e fixtures tied // to specific seeds) depends on staying stable. const restoredStageFocus = getValidatedStageFocusFromHash( stateRef.current ) setState(prev => ({ ...prev, ...(restoredStageFocus && { stageFocus: restoredStageFocus }), hasBooted: true, })) } syncToRoom() })() return () => { isMounted = false } }, [createInitialState, incrementDay, props.localforage, syncToRoom]) // isMenuOpen is otherwise only synced to the viewport at boot and on // stageFocus changes - without this, resizing the window (or rotating a // device) never opens/closes the sidebar until the next of those, which // in practice meant a full page reload. const previousWindowWidthRef = useRef(window.innerWidth) useEffect(() => { const handleResize = () => { const { innerWidth } = window const hasWidthChanged = innerWidth !== previousWindowWidthRef.current previousWindowWidthRef.current = innerWidth // doesMenuObstructStage is width-based, so a resize that only changed // height (e.g. a mobile on-screen keyboard opening/closing) has nothing // to update here. Without this guard, that kind of resize closes the // sidebar out from under the player mid-typing. if (!hasWidthChanged) return setState(previous => { const isMenuOpen = !doesMenuObstructStage() return previous.isMenuOpen === isMenuOpen ? previous : { ...previous, isMenuOpen } }) } window.addEventListener('resize', handleResize) return () => window.removeEventListener('resize', handleResize) }, []) // Post-increment day side effects useEffect(() => { if (!state.hasBooted || !nextDayStateRef.current) return let isMounted = true const { state: resolvedNextDayState, pendingNotifications, broadcastedPositionMessage, isFirstDay, } = nextDayStateRef.current nextDayStateRef.current = null void (async () => { try { await props.localforage?.setItem( 'state', reduceByPersistedKeys({ ...resolvedNextDayState, ...(isFirstDay && { historicalValueAdjustments: [] }), newDayNotifications: pendingNotifications, }) ) if (!isMounted) return const notifications = [...pendingNotifications] notifications .concat( isFirstDay ? [] : [{ message: PROGRESS_SAVED_MESSAGE, severity: 'info' }] ) .forEach(({ message, severity }) => boundReducersRef.current.showNotification(message, severity) ) if (resolvedNextDayState.isCombineEnabled) { if (resolvedNextDayState.stageFocus === stageFocusType.FIELD) { await sleep(1000) } if (!isMounted) return boundReducersRef.current.forRange( reducers.harvestPlot, Infinity, 0, 0 ) } } catch (e) { console.error(e) if (isMounted) { boundReducersRef.current.showNotification(JSON.stringify(e), 'error') } } finally { if (isMounted) { setState(previous => ({ ...previous, isWaitingForDayToCompleteIncrementing: false, })) if (broadcastedPositionMessage) { boundReducersRef.current.prependPendingPeerMessage( broadcastedPositionMessage ) } } } })() return () => { isMounted = false } }, [state.dayCount, state.hasBooted, props.localforage, setState]) // Cleanup on unmount. Uses stateRef (rather than depending on // state.heartbeatTimeoutId/state.peerRoom directly) so this only runs once, // on unmount — depending on those values re-fires this cleanup on every // heartbeat tick, which leaves the peer room every ~10s while online. useEffect(() => { return () => { if (stateRef.current.heartbeatTimeoutId) { clearTimeout(stateRef.current.heartbeatTimeoutId) } stateRef.current.peerRoom?.leave() } }, []) // ComponentDidUpdate for non-network effects (achievements, cow selection, menu menu, money) useEffect(() => { if (!state.hasBooted || !prevState) return const updatedAchievementsState = reducers.updateAchievements( state, prevState ) if (updatedAchievementsState !== state) { setState(previous => { const next = reducers.updateAchievements(previous, prevState) return next !== previous ? next : previous }) } if ( state.stageFocus === stageFocusType.COW_PEN && prevState.stageFocus !== stageFocusType.COW_PEN ) { setState(previous => ({ ...previous, selectedCowId: '' })) } if (state.stageFocus !== prevState.stageFocus && state.isMenuOpen) { setState(previous => { const isMenuOpen = !doesMenuObstructStage() return previous.isMenuOpen === isMenuOpen ? previous : { ...previous, isMenuOpen } }) } if (state.money < prevState.money) { setState(previous => ({ ...previous, todaysLosses: moneyTotal( previous.todaysLosses, state.money - prevState.money ), })) } showInventoryFullNotifications(prevState, state) showRecipeLearnedNotifications(prevState) }, [ state, prevState, showInventoryFullNotifications, showRecipeLearnedNotifications, ]) const redirect = state.redirect const gameState = { ...state, blockInput: isInputBlocked, features: propsFeatures ?? {}, fieldToolInventory, mulchInventory, isChatAvailable, levelEntitlements, plantableCropInventory, plantableTreeInventory, playerInventory, playerInventoryQuantities: getInventoryQuantities(state.inventory), shopInventory, viewList, viewTitle, } return { gameState, handlers, isInputBlocked, keyMap, keyHandlers, redirect, state, viewList, focusPreviousView, focusNextView, isChatAvailable, } }