@jeremyckahn/farmhand
Version:
A farming game
1,328 lines (1,158 loc) • 37.5 kB
JavaScript
import React from 'react'
import window from 'global/window.js'
import { Redirect } from 'react-router-dom'
import { GlobalHotKeys } from 'react-hotkeys'
import localforage from 'localforage'
import { v4 as uuid } from 'uuid'
import {
ThemeProvider,
StyledEngineProvider,
} from '@mui/material/styles/index.js'
import Drawer from '@mui/material/Drawer/index.js'
import Fab from '@mui/material/Fab/index.js'
import MenuIcon from '@mui/icons-material/Menu.js'
import HotelIcon from '@mui/icons-material/Hotel.js'
import KeyboardArrowLeft from '@mui/icons-material/KeyboardArrowLeft.js'
import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight.js'
import Tooltip from '@mui/material/Tooltip/index.js'
import MobileStepper from '@mui/material/MobileStepper/index.js'
// NOTE: This path is aliased to tryster/torrent in vite.config.js. The actual
// path can't be used here because it breaks either Vite or TypeScript.
import { joinRoom } from 'trystero'
import { SnackbarProvider } from 'notistack'
import debounce from 'lodash.debounce'
import throttle from 'lodash.throttle'
import classNames from 'classnames'
import { object } from 'prop-types'
import eventHandlers from '../../handlers/ui-events.js'
import {
handlePeerMetadataRequest,
handleCowTradeRequest,
handleCowTradeRequestAccept,
handleCowTradeRequestReject,
} from '../../handlers/peer-events.js'
import * as reducers from '../../game-logic/reducers/index.js'
// This must be imported here so that it can be overridden by component styles.
import './Farmhand.sass'
import AppBar from '../AppBar/index.js'
import Navigation from '../Navigation/index.js'
import ContextPane from '../ContextPane/index.js'
import Stage from '../Stage/index.js'
import NotificationSystem, {
snackbarProviderContentCallback,
} from '../NotificationSystem/index.js'
import UpdateNotifier from '../UpdateNotifier/index.js'
import theme from '../../mui-theme.js'
import { levelAchieved } from '../../utils/levelAchieved.js'
import {
computeMarketPositions,
createNewField,
createNewForest,
doesMenuObstructStage,
generateCow,
getAvailableShopInventory,
getItemCurrentValue,
getPeerMetadata,
inventorySpaceRemaining,
moneyTotal,
nullArray,
reduceByPersistedKeys,
sleep,
transformStateDataForImport,
} from '../../utils/index.js'
import { noop } from '../../utils/noop.js'
import { getLevelEntitlements } from '../../utils/getLevelEntitlements.js'
import { memoize } from '../../utils/memoize.js'
import { getData, postData } from '../../fetch-utils.js'
import { itemsMap, recipesMap } from '../../data/maps.js'
import {
dialogView,
fieldMode,
stageFocusType,
toolLevel,
toolType,
} from '../../enums.js'
import {
COW_TRADE_TIMEOUT,
DEFAULT_ROOM,
HEARTBEAT_INTERVAL_PERIOD,
INITIAL_STORAGE_LIMIT,
STAGE_TITLE_MAP,
STANDARD_LOAN_AMOUNT,
Z_INDEX,
STANDARD_VIEW_LIST,
} from '../../constants.js'
import {
CONNECTED_TO_ROOM,
LOAN_INCREASED,
POSITIONS_POSTED_NOTIFICATION,
RECIPE_LEARNED,
RECIPES_LEARNED,
} from '../../templates.js'
import {
CONNECTING_TO_SERVER,
COW_ALREADY_OWNED,
DATA_DELETED,
DISCONNECTED_FROM_SERVER,
INVENTORY_FULL_NOTIFICATION,
PROGRESS_SAVED_MESSAGE,
REQUESTED_COW_TRADE_UNAVAILABLE,
SERVER_ERROR,
} from '../../strings.js'
import { endpoints, features, rtcConfig, relayUrls } from '../../config.js'
import { scarecrow } from '../../data/items.js'
import { ChatRoom } from '../ChatRoom/index.js'
import { getInventoryQuantities } from './helpers/getInventoryQuantities.js'
import FarmhandContext from './Farmhand.context.js'
import { FarmhandReducers } from './FarmhandReducers.js'
const { CLEANUP, HARVEST, MINE, OBSERVE, WATER, PLANT } = fieldMode
// Utility object for reuse in no-ops to save on memory
const emptyObject = Object.freeze({})
export const computePlayerInventory = memoize(
/**
* @param {{ id: globalThis.farmhand.item['id'], quantity: number }[]} inventory
* @param {Record<string, number>} valueAdjustments
* @returns {globalThis.farmhand.item[]}
*/
(inventory, valueAdjustments) =>
inventory.map(({ quantity, id }) => ({
quantity,
...itemsMap[id],
value: getItemCurrentValue(itemsMap[id], valueAdjustments),
}))
)
export const getFieldToolInventory = memoize(
/**
* @param {globalThis.farmhand.state['inventory']} inventory
* @returns {globalThis.farmhand.item[]}
*/
inventory =>
inventory
.filter(({ id }) => {
const { enablesFieldMode } = itemsMap[id]
return (
typeof enablesFieldMode === 'string' && enablesFieldMode !== PLANT
)
})
.map(({ id, quantity }) => ({ ...itemsMap[id], quantity }))
)
export const getPlantableCropInventory = memoize(
/**
* @param {globalThis.farmhand.state['inventory']} inventory
* @returns {globalThis.farmhand.item[]}
*/
inventory =>
inventory
.filter(({ id }) => itemsMap[id].isPlantableCrop)
.map(({ id, quantity }) => ({ ...itemsMap[id], quantity }))
)
/**
* @param {Record<string, number>} valueAdjustments
* @param {Partial<Record<string, globalThis.farmhand.priceEvent>>} priceCrashes
* @param {Partial<Record<string, globalThis.farmhand.priceEvent>>} priceSurges
* @returns {Record<string, number>}
*/
const applyPriceEvents = (valueAdjustments, priceCrashes, priceSurges) => {
const patchedValueAdjustments = { ...valueAdjustments }
Object.keys(priceCrashes).forEach(itemId => {
patchedValueAdjustments[itemId] = 0.5
})
Object.keys(priceSurges).forEach(itemId => {
patchedValueAdjustments[itemId] = 1.5
})
return patchedValueAdjustments
}
export default class Farmhand extends FarmhandReducers {
/*!
* @member farmhand.Farmhand#state
* @type {farmhand.state}
*/
state = this.createInitialState()
// NOTE: This object construction is overwritten in initInputHandlers with
// properly-bound versions of each handler function. They are initially
// defined here as stubs in order to inform the type checker of the
// appropriate object structure.
handlers = {
...eventHandlers,
debounced: { ...eventHandlers },
}
/**
* @type {Record<string, string>}
*/
keyMap = {}
/**
* @type {Record<string, () => void>}
*/
keyHandlers = {}
static defaultProps = {
localforage: localforage.createInstance({
name: 'farmhand',
description: 'Persisted game data for Farmhand',
}),
features: {},
match: { path: '', params: {} },
}
/**
* @param {typeof Farmhand.defaultProps} props
*/
constructor(props) {
super(props)
this.initInputHandlers()
// This is an antipattern, but it's useful for debugging. The Farmhand
// component assumes that it is a singleton.
window.farmhand = this
}
getData = getData
postData = postData
get viewTitle() {
return STAGE_TITLE_MAP[this.state.stageFocus]
}
get fieldToolInventory() {
return getFieldToolInventory(this.state.inventory)
}
get playerInventory() {
const { inventory, valueAdjustments } = this.state
return computePlayerInventory(inventory, valueAdjustments)
}
get plantableCropInventory() {
return getPlantableCropInventory(this.state.inventory)
}
get viewList() {
const { CELLAR, COW_PEN, HOME, WORKSHOP, FOREST } = stageFocusType
/** @type (keyof typeof stageFocusType)[] */
const viewList = [...STANDARD_VIEW_LIST]
if (this.state.showHomeScreen) {
viewList.unshift(HOME)
}
if (this.isForestUnlocked && features.FOREST) {
viewList.push(FOREST)
}
if (this.state.purchasedCowPen) {
viewList.push(COW_PEN)
}
viewList.push(WORKSHOP)
if (this.state.purchasedCellar) {
viewList.push(CELLAR)
}
return viewList
}
get levelEntitlements() {
return getLevelEntitlements(levelAchieved(this.state.experience))
}
get shopInventory() {
return getAvailableShopInventory(this.levelEntitlements)
}
get peerMetadata() {
return getPeerMetadata(this.state)
}
get isInputBlocked() {
return (
this.state.isAwaitingNetworkRequest ||
this.state.isAwaitingCowTradeRequest ||
this.state.isWaitingForDayToCompleteIncrementing
)
}
get isChatAvailable() {
const { isOnline, room } = this.state
return isOnline && room !== DEFAULT_ROOM
}
get isForestUnlocked() {
return this.levelEntitlements.stageFocusType[stageFocusType.FOREST]
}
/**
* @returns {farmhand.state}
*/
createInitialState() {
return /** @type {farmhand.state} */ ({
activePlayers: null,
allowCustomPeerCowNames: false,
cellarInventory: [],
currentDialogView:
/** @type {globalThis.farmhand.dialogView} */ (dialogView.NONE),
completedAchievements: {},
cowForSale: generateCow(),
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: /** @type {globalThis.farmhand.fieldMode} */ (OBSERVE),
forest: createNewForest(),
getCowAccept: noop,
getCowReject: noop,
getCowTradeRequest: noop,
getPeerMetadata: noop,
hasBooted: 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: this.props.match.path.startsWith('/online'),
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: '',
selectedItemId: '',
priceCrashes: {},
priceSurges: {},
profitabilityStreak: 0,
record7dayProfitAverage: 0,
recordProfitabilityStreak: 0,
recordSingleDayProfit: 0,
revenue: 0,
redirect: '',
room: decodeURIComponent(this.props.match.params.room || DEFAULT_ROOM),
sendCowAccept: noop,
sendCowReject: noop,
purchasedCombine: 0,
purchasedComposter: 0,
purchasedCowPen: 0,
purchasedCellar: 0,
purchasedField: 0,
purchasedForest: 0,
purchasedSmelter: 0,
sendCowTradeRequest: noop,
showHomeScreen: true,
showNotifications: true,
stageFocus:
/** @type {globalThis.farmhand.stageFocusType} */ (stageFocusType.HOME),
todaysNotifications: [],
todaysLosses: 0,
todaysPurchases: {},
todaysRevenue: 0,
todaysStartingInventory: {},
toolLevels: /** @type {Record<import('../../enums.js').toolType, import('../../enums.js').toolLevel>} */ ({
[toolType.HOE]: toolLevel.DEFAULT,
[toolType.SCYTHE]: toolLevel.DEFAULT,
[toolType.SHOVEL]: toolLevel.UNAVAILABLE,
[toolType.WATERING_CAN]: toolLevel.DEFAULT,
}),
useAlternateEndDayButtonPosition: false,
valueAdjustments: {},
version: import.meta.env?.VITE_FARMHAND_PACKAGE_VERSION ?? '',
})
}
async initializeNewGame() {
await this.incrementDay(true)
this.setState(() => ({
historicalValueAdjustments: [],
}))
this.showNotification(LOAN_INCREASED('', STANDARD_LOAN_AMOUNT), 'info')
}
initInputHandlers() {
const debouncedInputRate = 50
Object.keys(eventHandlers).forEach(method => {
this.handlers[method] = eventHandlers[method].bind(this)
this.handlers.debounced[method] = debounce(
this.handlers[method],
debouncedInputRate
)
})
// NOTE: The dialog view mappings here MUST be kept in sync with the
// dialogTriggerTextMap map in Navigation.js. They MUST also be kept in
// sync with the player-facing documentation in KeybindingsView.js
this.keyMap = {
incrementDay: 'shift+c',
nextView: 'right',
openAccounting: 'b',
openAchievements: 'a',
openLog: 'l',
openPriceEvents: 'e',
openStats: 's',
openSettings: ',',
openKeybindings: 'shift+?',
previousView: 'left',
toggleMenu: 'm',
}
this.keyHandlers = {
incrementDay: () => this.incrementDay(),
nextView: this.focusNextView.bind(this),
openAccounting: () => this.openDialogView(dialogView.ACCOUNTING),
openAchievements: () => this.openDialogView(dialogView.ACHIEVEMENTS),
openLog: () => this.openDialogView(dialogView.FARMERS_LOG),
openPriceEvents: () => this.openDialogView(dialogView.PRICE_EVENTS),
openStats: () => this.openDialogView(dialogView.STATS),
openSettings: () => this.openDialogView(dialogView.SETTINGS),
openKeybindings: () => this.openDialogView(dialogView.KEYBINDINGS),
previousView: this.focusPreviousView.bind(this),
selectHoe: () =>
this.handlers.handleFieldModeSelect(
/** @type {globalThis.farmhand.fieldMode} */ (CLEANUP)
),
selectScythe: () =>
this.handlers.handleFieldModeSelect(
/** @type {globalThis.farmhand.fieldMode} */ (HARVEST)
),
selectWateringCan: () =>
this.handlers.handleFieldModeSelect(
/** @type {globalThis.farmhand.fieldMode} */ (WATER)
),
selectShovel: () => {
if (this.state.toolLevels[toolType.SHOVEL] !== toolLevel.UNAVAILABLE) {
this.handlers.handleFieldModeSelect(
/** @type {globalThis.farmhand.fieldMode} */ (MINE)
)
}
},
toggleMenu: () => this.handlers.handleMenuToggle(),
}
nullArray(9).forEach((_, i) => {
const index = i + 1
const key = `numberKey${index}`
this.keyMap[key] = String(index)
this.keyHandlers[key] = () => {
const viewName = this.viewList[i]
if (typeof viewName === 'string') {
this.setState({ stageFocus: stageFocusType[viewName] })
}
}
})
if (import.meta.env?.MODE === 'development') {
Object.assign(this.keyMap, {
clearPersistedData: 'shift+d',
waterAllPlots: 'w',
})
}
Object.assign(this.keyHandlers, {
clearPersistedData: () => this.clearPersistedData(),
waterAllPlots: () => this.waterAllPlots(),
})
}
async componentDidMount() {
const state = await this.props.localforage.getItem('state')
if (state) {
const sanitizedState = transformStateDataForImport({
...this.createInitialState(),
// eslint-disable-next-line
.../** @type {Partial<farmhand.state>} */ (state),
})
const { isCombineEnabled, newDayNotifications } = sanitizedState
this.setState({ ...sanitizedState, newDayNotifications: [] }, () => {
newDayNotifications.forEach(({ message, severity }) => {
// Defer these notifications so that notistack doesn't swallow all
// but the last one.
setTimeout(() => this.showNotification(message, severity), 0)
if (isCombineEnabled) {
this.forRange(reducers.harvestPlot, Infinity, 0, 0)
}
})
})
} else {
await this.initializeNewGame()
}
this.syncToRoom()
this.setState({ hasBooted: true })
}
componentDidUpdate(_prevProps, prevState) {
const {
hasBooted,
heartbeatTimeoutId,
isMenuOpen,
isOnline,
money,
peerRoom,
room,
stageFocus,
} = this.state
// The operations after this if block concern transient gameplay state, but
// componentDidUpdate runs as part of the rehydration/bootup process. So,
// check to see if the app has completed booting before doing anything with
// this transient state.
if (!hasBooted) {
return
}
const {
match: {
path,
params: { room: newRoom = room },
},
} = this.props
const decodedRoom = decodeURIComponent(newRoom)
// NOTE: This indicates that the client should attempt to connect to the server
const newIsOnline = path.startsWith('/online')
if (newIsOnline !== this.state.isOnline || decodedRoom !== room) {
this.setState(() => ({
isOnline: newIsOnline,
redirect: '',
room: decodedRoom,
}))
}
if (isOnline !== prevState.isOnline || room !== prevState.room) {
if (newIsOnline) {
this.syncToRoom()
}
if (!isOnline && typeof heartbeatTimeoutId === 'number') {
clearTimeout(heartbeatTimeoutId)
this.setState({
activePlayers: null,
heartbeatTimeoutId: null,
peerRoom: null,
})
}
}
if (isOnline === false && prevState.isOnline === true) {
this.showNotification(DISCONNECTED_FROM_SERVER, 'info')
}
const updatedAchievementsState = reducers.updateAchievements(
this.state,
prevState
)
if (updatedAchievementsState !== this.state) {
this.setState(() => updatedAchievementsState)
}
if (
this.state.stageFocus === stageFocusType.COW_PEN &&
prevState.stageFocus !== stageFocusType.COW_PEN
) {
this.setState(() => ({ selectedCowId: '' }))
}
if (stageFocus !== prevState.stageFocus) {
if (isMenuOpen) {
this.setState(() => ({ isMenuOpen: !doesMenuObstructStage() }))
}
}
if (money < prevState.money) {
this.setState(({ todaysLosses }) => ({
todaysLosses: moneyTotal(todaysLosses, money - prevState.money),
}))
}
if (peerRoom !== prevState.peerRoom) {
if (peerRoom) {
peerRoom.onPeerJoin(id => {
this.addPeer(id)
})
peerRoom.onPeerLeave(id => {
this.removePeer(id)
})
const [sendPeerMetadata, getPeerMetadata] = peerRoom.makeAction(
'peerMetadata'
)
getPeerMetadata((
/** @type {[object, string]} */
...args
) => handlePeerMetadataRequest(this, ...args))
const [sendCowTradeRequest, getCowTradeRequest] = peerRoom.makeAction(
'cowTrade'
)
getCowTradeRequest((
/** @type {[object, string]} */
...args
) => handleCowTradeRequest(this, ...args))
const [sendCowAccept, getCowAccept] = peerRoom.makeAction('cowAccept')
getCowAccept((
/** @type {[object, string]} */
...args
) => handleCowTradeRequestAccept(this, ...args))
const [sendCowReject, getCowReject] = peerRoom.makeAction('cowReject')
getCowReject((
/** @type {[object]} */
...args
) => handleCowTradeRequestReject(this, ...args))
this.setState({
getCowAccept,
getCowReject,
getCowTradeRequest,
getPeerMetadata,
pendingPeerMessages: [],
sendCowAccept,
sendCowReject,
sendCowTradeRequest,
sendPeerMetadata: this.wrapSendPeerMetadata(sendPeerMetadata),
})
sendPeerMetadata(this.peerMetadata)
} else {
// This player has gone offline.
prevState.peerRoom.leave()
this.setState({ peers: {}, sendPeerMetadata: null })
}
}
;[
'showInventoryFullNotifications',
'showRecipeLearnedNotifications',
].forEach(fn => this[fn](prevState))
this.state.sendPeerMetadata?.(this.peerMetadata)
}
/**
* @param {Function} sendPeerMetadata Raw send action callback created by
* Trystero's makeAction function.
* @return {Function}
*/
wrapSendPeerMetadata(sendPeerMetadata) {
return throttle(
(...args) => {
sendPeerMetadata(...args)
this.setState(() => ({
pendingPeerMessages: [],
}))
},
5000,
{
trailing: true,
}
)
}
/**
* @param {globalThis.farmhand.cow} peerPlayerCow
*/
tradeForPeerCow(peerPlayerCow) {
this.setState(state => {
const {
cowIdOfferedForTrade,
cowInventory,
peers,
sendCowTradeRequest,
} = state
if (!sendCowTradeRequest) return null
const { ownerId } = peerPlayerCow
const [peerId] =
Object.entries(peers).find(([, peer]) => peer?.playerId === ownerId) ??
[]
if (!peerId) {
console.error(
`Owner not found for cow ${JSON.stringify(peerPlayerCow)}`
)
return null
}
const playerAlreadyOwnsRequestedCow = cowInventory.find(
({ id }) => id === peerPlayerCow.id
)
if (playerAlreadyOwnsRequestedCow) {
console.error(`Cow ID ${peerPlayerCow.id} is already in inventory`)
return reducers.showNotification(state, COW_ALREADY_OWNED, 'error')
}
const cowToTradeAway = cowInventory.find(
({ id }) => id === cowIdOfferedForTrade
)
if (!cowToTradeAway) {
console.error(`Cow ID ${cowIdOfferedForTrade} not found`)
return null
}
const cowTradeTimeoutId = setTimeout(
this.handleCowTradeTimeout,
COW_TRADE_TIMEOUT
)
sendCowTradeRequest(
{
cowOffered: { ...cowToTradeAway, isUsingHuggingMachine: false },
cowRequested: peerPlayerCow,
},
peerId
)
return { cowTradeTimeoutId, isAwaitingCowTradeRequest: true }
}, noop)
}
handleCowTradeTimeout = () => {
if (typeof this.state.cowTradeTimeoutId === 'number') {
this.showNotification(REQUESTED_COW_TRADE_UNAVAILABLE, 'error')
this.setState({
cowTradeTimeoutId: null,
isAwaitingCowTradeRequest: false,
})
console.error('Cow trade request timed out')
}
}
async clearPersistedData() {
await this.props.localforage.clear()
this.showNotification(DATA_DELETED)
}
async syncToRoom() {
const { isOnline, priceCrashes, priceSurges, room } = this.state
if (!isOnline) {
return
}
this.showNotification(CONNECTING_TO_SERVER, 'info')
try {
this.setState({
isAwaitingNetworkRequest: true,
peers: {},
})
this.state.peerRoom?.leave()
const { valueAdjustments } = await getData(endpoints.getMarketData, {
farmId: this.state.playerId,
room: room,
})
this.scheduleHeartbeat()
const relayRedundancy = 4
this.setState({
activePlayers: 1,
peerRoom: joinRoom(
{
appId: import.meta.env?.VITE_NAME,
relayUrls,
relayRedundancy,
rtcConfig,
},
room
),
valueAdjustments: applyPriceEvents(
valueAdjustments,
priceCrashes,
priceSurges
),
})
this.showNotification(CONNECTED_TO_ROOM('', room), 'success')
} catch (e) {
// TODO: Add some reasonable fallback behavior in case the server request
// fails. Possibility: Regenerate valueAdjustments and notify the user
// they are offline.
this.showNotification(SERVER_ERROR, 'error')
console.error(e)
// NOTE: Syncing failed, so take the user offline
this.setState(() => {
return {
redirect: '/',
cowIdOfferedForTrade: '',
}
})
}
this.setState({
isAwaitingNetworkRequest: false,
isAwaitingCowTradeRequest: false,
})
}
scheduleHeartbeat() {
const { heartbeatTimeoutId } = this.state
clearTimeout(heartbeatTimeoutId ?? -1)
this.setState(() => ({
heartbeatTimeoutId: setTimeout(async () => {
this.setState(({ money, activePlayers }) => ({
activePlayers,
money: moneyTotal(money, activePlayers),
}))
this.scheduleHeartbeat()
}, HEARTBEAT_INTERVAL_PERIOD),
}))
}
/**
* @param {farmhand.state} prevState
*/
showInventoryFullNotifications(prevState) {
if (
inventorySpaceRemaining(prevState) > 0 &&
inventorySpaceRemaining(this.state) <= 0
) {
this.showNotification(INVENTORY_FULL_NOTIFICATION, 'warning')
}
}
/*!
* @param {farmhand.state} prevState
*/
showRecipeLearnedNotifications({ learnedRecipes: previousLearnedRecipes }) {
let learnedRecipes = []
Object.keys(this.state.learnedRecipes).forEach(recipeId => {
if (!previousLearnedRecipes.hasOwnProperty(recipeId)) {
learnedRecipes.push(recipesMap[recipeId])
}
})
if (learnedRecipes.length > 1) {
this.showNotification(RECIPES_LEARNED('', learnedRecipes))
} else if (learnedRecipes.length === 1) {
this.showNotification(RECIPE_LEARNED('', learnedRecipes[0]))
}
}
/*!
* @param {Object} [overrides] Data to patch into this.state when persisting.
* @return {Promise}
*/
persistState(overrides = {}) {
return this.props.localforage.setItem(
'state',
reduceByPersistedKeys({
...this.state,
...overrides,
})
)
}
async updateServerForNextDay() {
/** @type {globalThis.farmhand.notification[]} */
const serverMessages = []
/**
* @type {string | null}
*/
let broadcastedPositionMessage = null
this.setState(() => ({ isAwaitingNetworkRequest: true }))
/**
* @type {Record<string, number> | undefined}
*/
let serverValueAdjustments
if (this.state.isOnline) {
const {
inventory,
room,
todaysPurchases,
todaysStartingInventory,
} = this.state
const positions = computeMarketPositions(
todaysStartingInventory,
todaysPurchases,
inventory
)
try {
serverValueAdjustments = (
await postData(endpoints.postDayResults, {
positions,
room,
})
).valueAdjustments
if (Object.keys(positions).length) {
serverMessages.push({
message: POSITIONS_POSTED_NOTIFICATION('', 'You', positions),
severity: 'info',
})
broadcastedPositionMessage = POSITIONS_POSTED_NOTIFICATION(
'',
'',
positions
)
}
} catch (e) {
// NOTE: This will get reached when there's an issue posting data to the server.
serverMessages.push({
message: SERVER_ERROR,
severity: 'error',
})
this.setState(() => ({
redirect: '/',
cowIdOfferedForTrade: '',
isAwaitingNetworkRequest: false,
}))
console.error(e)
}
}
return {
broadcastedPositionMessage,
serverMessages,
serverValueAdjustments,
}
}
async incrementDay(isFirstDay = false) {
const {
broadcastedPositionMessage,
serverMessages,
serverValueAdjustments,
} = await this.updateServerForNextDay()
/** @type {globalThis.farmhand.notification[]} */
let pendingNotifications = []
// This would be cleaner if setState was called after localForage.setItem,
// but updating the state first makes for a more responsive user
// experience. The persisted state is computed post-update and stored
// asynchronously, thus avoiding state changes from being blocked.
this.setState(
/**
* @param {farmhand.state} prev
* @return {Partial<farmhand.state>}
*/
prev => {
const nextDayState = reducers.computeStateForNextDay(prev, isFirstDay)
pendingNotifications = [
...serverMessages,
...nextDayState.newDayNotifications,
]
nextDayState.valueAdjustments = applyPriceEvents(
serverValueAdjustments ?? nextDayState.valueAdjustments,
nextDayState.priceCrashes,
nextDayState.priceSurges
)
nextDayState.isAwaitingNetworkRequest = false
return {
...nextDayState,
isWaitingForDayToCompleteIncrementing: true,
newDayNotifications: [],
todaysNotifications: [],
}
},
async () => {
try {
await this.persistState({
// Old pendingNotifications are persisted so that they can be
// shown to the player when the app reloads.
newDayNotifications: pendingNotifications,
})
const notifications = [...pendingNotifications]
notifications
.concat(
isFirstDay
? []
: [{ message: PROGRESS_SAVED_MESSAGE, severity: 'info' }]
)
.forEach(({ message, severity }) =>
this.showNotification(message, severity)
)
if (this.state.isCombineEnabled) {
if (this.state.stageFocus === stageFocusType.FIELD) {
// Allow the mature crops' animation to complete.
await sleep(1000)
}
this.forRange(reducers.harvestPlot, Infinity, 0, 0)
}
} catch (e) {
console.error(e)
this.showNotification(JSON.stringify(e), 'error')
} finally {
this.setState(() => ({
isWaitingForDayToCompleteIncrementing: false,
}))
if (broadcastedPositionMessage) {
this.messagePeers(broadcastedPositionMessage)
}
}
}
)
}
/*!
* @param {farmhand.module:enums.dialogView} dialogView
*/
openDialogView(dialogView) {
this.setState({ currentDialogView: dialogView, isDialogViewOpen: true })
}
closeDialogView() {
this.setState({ isDialogViewOpen: false })
}
focusNextView() {
if (document.activeElement?.getAttribute('role') === 'tab') return
const { viewList } = this
this.setState(({ stageFocus }) => {
const currentViewIndex = viewList.indexOf(stageFocus)
return { stageFocus: viewList[(currentViewIndex + 1) % viewList.length] }
})
}
focusPreviousView() {
if (document.activeElement?.getAttribute('role') === 'tab') return
const { viewList } = this
this.setState(({ stageFocus }) => {
const currentViewIndex = viewList.indexOf(stageFocus)
return {
stageFocus:
viewList[
currentViewIndex === 0
? viewList.length - 1
: (currentViewIndex - 1) % viewList.length
],
}
})
}
/**
* @param {string} message
* @param {string?} [severity]
*/
messagePeers(message, severity) {
this.prependPendingPeerMessage(message, severity)
}
render() {
const {
props: { features },
state: { redirect },
fieldToolInventory,
handlers,
isChatAvailable,
keyHandlers,
keyMap,
levelEntitlements,
plantableCropInventory,
playerInventory,
shopInventory,
viewList,
viewTitle,
} = this
const blockInput = this.isInputBlocked
// Bundle up the raw state and the computed state into one object to be
// passed down through the component tree.
const gameState = {
...this.state,
blockInput,
features,
fieldToolInventory,
isChatAvailable,
levelEntitlements,
plantableCropInventory,
playerInventory,
playerInventoryQuantities: getInventoryQuantities(this.state.inventory),
shopInventory,
viewList,
viewTitle,
}
return (
<GlobalHotKeys
{...{
allowChanges: true,
keyMap: blockInput ? emptyObject : keyMap,
handlers: blockInput ? emptyObject : keyHandlers,
}}
>
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<SnackbarProvider
{...{
anchorOrigin: { vertical: 'top', horizontal: 'right' },
classes: {
containerRoot: 'Farmhand notification-container',
},
content: snackbarProviderContentCallback,
maxSnack: 4,
}}
>
{redirect && <Redirect {...{ to: redirect }} />}
<FarmhandContext.Provider
value={{
gameState,
handlers,
}}
>
<div
{...{
className: classNames(
'Farmhand farmhand-root fill',
this.state.isMenuOpen ? 'menu-open' : 'menu-closed',
{
'use-alternate-end-day-button-position': this.state
.useAlternateEndDayButtonPosition,
'block-input': blockInput,
'has-booted': this.state.hasBooted,
}
),
}}
>
<UpdateNotifier />
<AppBar />
<Drawer
{...{
className: 'sidebar-wrapper',
open: gameState.isMenuOpen,
variant: 'persistent',
role: 'complementary',
PaperProps: {
className: 'sidebar',
},
}}
>
<Navigation />
<ContextPane />
<div {...{ className: 'spacer' }} />
</Drawer>
<Stage />
{/*
These controls need to be at this top level instead of the Stage
because of scrolling issues in iOS.
*/}
<div className="bottom-controls">
<MobileStepper
variant="dots"
steps={viewList.length}
position="static"
activeStep={viewList.indexOf(this.state.stageFocus)}
className=""
backButton={null}
nextButton={null}
/>
<div className="fab-buttons buttons">
<Fab
{...{
'aria-label': 'Previous view',
color: 'primary',
onClick: () => this.focusPreviousView(),
}}
>
<KeyboardArrowLeft />
</Fab>
<Fab
{...{
className: classNames('menu-button', {
'is-open': this.state.isMenuOpen,
}),
color: 'primary',
'aria-label': 'Open drawer',
onClick: () => handlers.handleMenuToggle(),
}}
>
<MenuIcon />
</Fab>
<Fab
{...{
'aria-label': 'Next view',
color: 'primary',
onClick: () => this.focusNextView(),
}}
>
<KeyboardArrowRight />
</Fab>
</div>
</div>
<Tooltip
{...{
placement: 'left',
title: (
<>
<p>
End the day to save your progress and advance the
game.
</p>
<p>(shift + c)</p>
</>
),
}}
>
<Fab
{...{
'aria-label':
'End the day to save your progress and advance the game.',
className: 'end-day',
color: 'error',
onClick: handlers.handleClickEndDayButton,
sx: {
zIndex: Z_INDEX.END_DAY_BUTTON,
},
}}
>
<HotelIcon />
</Fab>
</Tooltip>
</div>
{isChatAvailable ? <ChatRoom /> : null}
<NotificationSystem />
</FarmhandContext.Provider>
</SnackbarProvider>
</ThemeProvider>
</StyledEngineProvider>
</GlobalHotKeys>
)
}
}
Farmhand.propTypes = {
features: object,
history: object,
location: object,
match: object.isRequired,
}