diff --git a/.env b/.env
index d173a43e0..353b7cd69 100644
--- a/.env
+++ b/.env
@@ -3,6 +3,8 @@ REACT_APP_API_ROOT=https://farmhand.vercel.app/
REACT_APP_NAME=$npm_package_name
REACT_APP_VERSION=$npm_package_version
+REACT_APP_ENABLE_KEGS=true
+
# Silence warnings from dev server
# https://stackoverflow.com/a/70834076
GENERATE_SOURCEMAP=false
diff --git a/src/components/AchievementsView/AchievementsView.js b/src/components/AchievementsView/AchievementsView.js
index 25ee68a71..03a390306 100644
--- a/src/components/AchievementsView/AchievementsView.js
+++ b/src/components/AchievementsView/AchievementsView.js
@@ -9,7 +9,7 @@ import FarmhandContext from '../Farmhand/Farmhand.context'
import ProgressBar from '../ProgressBar'
import Achievement from '../Achievement'
import achievements from '../../data/achievements'
-import { memoize } from '../../utils'
+import { memoize } from '../../utils/memoize'
import './AchievementsView.sass'
diff --git a/src/components/Cellar/Cellar.js b/src/components/Cellar/Cellar.js
index e73492669..e04987c2a 100644
--- a/src/components/Cellar/Cellar.js
+++ b/src/components/Cellar/Cellar.js
@@ -1,29 +1,17 @@
-import React, { useContext, useState } from 'react'
+import React, { useState } from 'react'
import AppBar from '@material-ui/core/AppBar'
import Tab from '@material-ui/core/Tab'
import Tabs from '@material-ui/core/Tabs'
-import { recipesMap } from '../../data/maps'
-import { recipeType } from '../../enums'
-
-import FarmhandContext from '../Farmhand/Farmhand.context'
-
+import { CellarInventoryTabPanel } from './CellarInventoryTabPanel'
import { FermentationTabPanel } from './FermentationTabPanel'
import { a11yProps } from './TabPanel'
import './Cellar.sass'
export const Cellar = () => {
- const {
- gameState: { learnedRecipes },
- } = useContext(FarmhandContext)
-
const [currentTab, setCurrentTab] = useState(0)
- const learnedFermentationRecipes = Object.keys(learnedRecipes).filter(
- recipeId => recipesMap[recipeId].recipeType === recipeType.FERMENTATION
- )
-
return (
@@ -32,14 +20,12 @@ export const Cellar = () => {
onChange={(_e, newTab) => setCurrentTab(newTab)}
aria-label="Cellar tabs"
>
-
+
+
-
+
+
)
}
diff --git a/src/components/Cellar/CellarInventoryTabPanel.js b/src/components/Cellar/CellarInventoryTabPanel.js
new file mode 100644
index 000000000..b4cd55282
--- /dev/null
+++ b/src/components/Cellar/CellarInventoryTabPanel.js
@@ -0,0 +1,71 @@
+/** @typedef {import("../../index").farmhand.keg} keg */
+import React, { useContext } from 'react'
+import { number } from 'prop-types'
+import Divider from '@material-ui/core/Divider'
+import Card from '@material-ui/core/Card'
+import CardContent from '@material-ui/core/CardContent'
+import ReactMarkdown from 'react-markdown'
+
+import FarmhandContext from '../Farmhand/Farmhand.context'
+import { KEG_INTEREST_RATE, PURCHASEABLE_CELLARS } from '../../constants'
+
+import { integerString } from '../../utils'
+
+import { TabPanel } from './TabPanel'
+import { Keg } from './Keg'
+
+/**
+ * @param {Object} props
+ * @param {number} props.index
+ * @param {number} props.currentTab
+ */
+export const CellarInventoryTabPanel = ({ index, currentTab }) => {
+ /**
+ * @type {{
+ * gameState: {
+ * cellarInventory:Array.,
+ * purchasedCellar: number
+ * }
+ * }}
+ */
+ const {
+ gameState: { cellarInventory, purchasedCellar },
+ } = useContext(FarmhandContext)
+
+ return (
+
+
+ Capacity: {integerString(cellarInventory.length)} /{' '}
+ {integerString(PURCHASEABLE_CELLARS.get(purchasedCellar).space)}
+
+
+ {cellarInventory.map(keg => (
+ -
+
+
+ ))}
+
+
+
+
+ )
+}
+
+CellarInventoryTabPanel.propTypes = {
+ currentTab: number.isRequired,
+ index: number.isRequired,
+}
diff --git a/src/components/Cellar/FermentationTabPanel.js b/src/components/Cellar/FermentationTabPanel.js
index 29da43026..7a575e37f 100644
--- a/src/components/Cellar/FermentationTabPanel.js
+++ b/src/components/Cellar/FermentationTabPanel.js
@@ -1,26 +1,17 @@
import React from 'react'
-import { number, array } from 'prop-types'
+import { number } from 'prop-types'
import Divider from '@material-ui/core/Divider'
import Card from '@material-ui/core/Card'
import CardContent from '@material-ui/core/CardContent'
import ReactMarkdown from 'react-markdown'
-import { recipeType } from '../../enums'
-import { recipeCategories } from '../../data/maps'
-import { RecipeList } from '../RecipeList/RecipeList'
+import { FermentationRecipeList } from '../FermentationRecipeList/FermentationRecipeList'
import { TabPanel } from './TabPanel'
-export const FermentationTabPanel = ({
- index,
- currentTab,
- learnedFermentationRecipes,
-}) => (
+export const FermentationTabPanel = ({ index, currentTab }) => (
-
+
-
@@ -30,7 +21,8 @@ export const FermentationTabPanel = ({
{...{
linkTarget: '_blank',
className: 'markdown',
- source: `Fermentation recipes are learned by selling crops. Sell as much as you can of a wide variety of items!`,
+ source:
+ 'Some items can be fermented. Fermented items become much more valuable over time!',
}}
/>
@@ -43,5 +35,4 @@ export const FermentationTabPanel = ({
FermentationTabPanel.propTypes = {
currentTab: number.isRequired,
index: number.isRequired,
- learnedFermentationRecipes: array.isRequired,
}
diff --git a/src/components/Cellar/Keg.js b/src/components/Cellar/Keg.js
new file mode 100644
index 000000000..3f028d680
--- /dev/null
+++ b/src/components/Cellar/Keg.js
@@ -0,0 +1,118 @@
+/** @typedef {import("../../index").farmhand.keg} keg */
+import React, { useContext } from 'react'
+import { object } from 'prop-types'
+import Card from '@material-ui/core/Card'
+import CardHeader from '@material-ui/core/CardHeader'
+import CardActions from '@material-ui/core/CardActions'
+import Button from '@material-ui/core/Button'
+
+import { itemsMap } from '../../data/maps'
+import { items } from '../../img'
+
+import FarmhandContext from '../Farmhand/Farmhand.context'
+import { getKegValue } from '../../utils/getKegValue'
+import { moneyString } from '../../utils/moneyString'
+import { getSalePriceMultiplier } from '../../utils'
+import { FERMENTED_CROP_NAME } from '../../templates'
+import AnimatedNumber from '../AnimatedNumber'
+
+import './Keg.sass'
+
+/**
+ * @param {Object} props
+ * @param {keg} props.keg
+ */
+export function Keg({ keg }) {
+ /**
+ * @type {{
+ * handlers: {
+ * handleSellKegClick: function(keg): void,
+ * handleThrowAwayKegClick: function(keg): void
+ * },
+ * gameState: {
+ * completedAchievements: Object.
+ * }
+ * }}
+ */
+ const {
+ handlers: { handleSellKegClick, handleThrowAwayKegClick },
+ gameState: { completedAchievements },
+ } = useContext(FarmhandContext)
+
+ const item = itemsMap[keg.itemId]
+ const fermentationRecipeName = FERMENTED_CROP_NAME`${item}`
+
+ const handleSellClick = () => {
+ handleSellKegClick(keg)
+ }
+
+ const handleThrowAwayClick = () => {
+ handleThrowAwayKegClick(keg)
+ }
+
+ const canBeSold = keg.daysUntilMature <= 0
+ const kegValue =
+ getKegValue(keg) * getSalePriceMultiplier(completedAchievements)
+
+ return (
+
+
+ }
+ subheader={
+ <>
+ {canBeSold ? (
+
Days since ready: {Math.abs(keg.daysUntilMature)}
+ ) : (
+ Days until ready: {keg.daysUntilMature}
+ )}
+ {canBeSold ? (
+
+ Current value:{' '}
+
+
+ ) : null}
+ >
+ }
+ >
+
+
+ {!canBeSold ? (
+
+ ) : null}
+
+
+ )
+}
+
+Keg.propTypes = {
+ keg: object.isRequired,
+}
diff --git a/src/components/Cellar/Keg.sass b/src/components/Cellar/Keg.sass
new file mode 100644
index 000000000..837e3fc1d
--- /dev/null
+++ b/src/components/Cellar/Keg.sass
@@ -0,0 +1,7 @@
+.Keg
+ position: relative
+
+ .MuiCardHeader-avatar
+ img
+ width: 3em
+ filter: drop-shadow(1px 1px 2px rgba(255, 0, 131, 0.63))
diff --git a/src/components/CowCard/Subheader/Subheader.js b/src/components/CowCard/Subheader/Subheader.js
index 43a282dd2..1ffb2b6ac 100644
--- a/src/components/CowCard/Subheader/Subheader.js
+++ b/src/components/CowCard/Subheader/Subheader.js
@@ -16,9 +16,9 @@ import {
getPlayerName,
integerString,
isCowInBreedingPen,
- memoize,
nullArray,
} from '../../../utils'
+import { memoize } from '../../../utils/memoize'
import { huggingMachine } from '../../../data/items'
import Bloodline from '../Bloodline'
diff --git a/src/components/Farmhand/Farmhand.js b/src/components/Farmhand/Farmhand.js
index 6af8e428a..da33df018 100644
--- a/src/components/Farmhand/Farmhand.js
+++ b/src/components/Farmhand/Farmhand.js
@@ -1,3 +1,6 @@
+/** @typedef {import("../../index").farmhand.item} farmhand.item */
+/** @typedef {import("../../index").farmhand.cow} farmhand.cow */
+/** @typedef {import("../../index").farmhand.keg} farmhand.keg */
import React, { Component } from 'react'
import window from 'global/window'
import { Redirect } from 'react-router-dom'
@@ -47,17 +50,17 @@ import {
farmProductsSold,
getAvailableShopInventory,
getItemCurrentValue,
- getLevelEntitlements,
getPeerMetadata,
inventorySpaceRemaining,
levelAchieved,
- memoize,
moneyTotal,
nullArray,
reduceByPersistedKeys,
sleep,
transformStateDataForImport,
} from '../../utils'
+import { getLevelEntitlements } from '../../utils/getLevelEntitlements'
+import { memoize } from '../../utils/memoize'
import { getData, postData } from '../../fetch-utils'
import { itemsMap, recipesMap } from '../../data/maps'
import {
@@ -106,7 +109,7 @@ import { scarecrow } from '../../data/items'
import { getInventoryQuantities } from './helpers/getInventoryQuantities'
import FarmhandContext from './Farmhand.context'
-const { CLEANUP, HARVEST, MINE, OBSERVE, WATER } = fieldMode
+const { CLEANUP, HARVEST, MINE, OBSERVE, WATER, PLANT } = fieldMode
// Utility object for reuse in no-ops to save on memory
const emptyObject = Object.freeze({})
@@ -133,10 +136,7 @@ export const getFieldToolInventory = memoize(inventory =>
.filter(({ id }) => {
const { enablesFieldMode } = itemsMap[id]
- return (
- typeof enablesFieldMode === 'string' &&
- enablesFieldMode !== fieldMode.PLANT
- )
+ return typeof enablesFieldMode === 'string' && enablesFieldMode !== PLANT
})
.map(({ id }) => itemsMap[id])
)
@@ -175,6 +175,7 @@ const applyPriceEvents = (valueAdjustments, priceCrashes, priceSurges) => {
* @type {Object}
* @property {number?} activePlayers
* @property {boolean} allowCustomPeerCowNames
+ * @property {Array.} cellarInventory
* @property {farmhand.module:enums.dialogView} currentDialogView
* @property {Object.} completedAchievements Keys are
* achievement ids.
@@ -209,14 +210,19 @@ const applyPriceEvents = (valueAdjustments, priceCrashes, priceSurges) => {
* future-facing flexibility.
* @property {number} hoveredPlotRangeSize
* @property {string} id
- * @property {Array.<{ id: farmhand.item, quantity: number }>} inventory
+ * @property {Array.<{ id: farmhand.item.id, quantity: number }>} inventory
* @property {number} inventoryLimit Is -1 if inventory is unlimited.
* @property {boolean} isAwaitingCowTradeRequest
* @property {boolean} isAwaitingNetworkRequest
* @property {boolean} isCombineEnabled
* @property {boolean} isMenuOpen
* @property {Object} itemsSold Keys are items IDs, values are the number of
- * that item sold.
+ * that item sold. The numbers in this map are inclusive of the corresponding
+ * ones in cellarItemsSold and represent the grand total of each item sold.
+ * @property {Object} cellarItemsSold Keys are items IDs, values are the number
+ * of that cellar item sold. The numbers in this map represent a subset of the
+ * corresponding ones in itemsSold. cellarItemsSold is intended to be used for
+ * internal bookkeeping.
* @property {boolean} isDialogViewOpen
* @property {boolean} isOnline Whether the player is playing online.
* @property {boolean} isWaitingForDayToCompleteIncrementing
@@ -242,6 +248,7 @@ const applyPriceEvents = (valueAdjustments, priceCrashes, priceSurges) => {
* itemIds.
* @property {number} purchasedCombine
* @property {number} purchasedCowPen
+ * @property {number} purchasedCellar
* @property {number} purchasedField
* @property {number} profitabilityStreak
* @property {number} record7dayProfitAverage
@@ -368,6 +375,7 @@ export default class Farmhand extends Component {
return {
activePlayers: null,
allowCustomPeerCowNames: false,
+ cellarInventory: [],
currentDialogView: dialogView.NONE,
completedAchievements: {},
cowForSale: {},
@@ -399,6 +407,7 @@ export default class Farmhand extends Component {
isCombineEnabled: false,
isMenuOpen: !doesMenuObstructStage(),
itemsSold: {},
+ cellarItemsSold: {},
isDialogViewOpen: false,
isOnline: this.props.match.path.startsWith('/online'),
isWaitingForDayToCompleteIncrementing: false,
@@ -546,6 +555,7 @@ export default class Farmhand extends Component {
'harvestPlot',
'hugCow',
'makeRecipe',
+ 'makeFermentationRecipe',
'modifyCow',
'offerCow',
'plantInPlot',
@@ -560,10 +570,12 @@ export default class Farmhand extends Component {
'purchaseSmelter',
'purchaseStorageExpansion',
'removeCowFromInventory',
+ 'removeKegFromCellar',
'removePeer',
'selectCow',
'sellCow',
'sellItem',
+ 'sellKeg',
'setScarecrow',
'setSprinkler',
'showNotification',
diff --git a/src/components/Farmhand/Farmhand.sass b/src/components/Farmhand/Farmhand.sass
index f2b85e4d3..a5a72d5a2 100644
--- a/src/components/Farmhand/Farmhand.sass
+++ b/src/components/Farmhand/Farmhand.sass
@@ -210,6 +210,7 @@ body
&.card-list
flex-grow: 1
+ margin-bottom: 1em
li
margin: 1em 0
@@ -269,7 +270,7 @@ body
// bottom padding for the stage and sidebar are not respected after
// interacting with the elements within them.
.spacer
- min-height: 6.5em
+ min-height: 7.5em
.MuiInput-root
margin: 1em 0
diff --git a/src/components/FermentationRecipeList/FermentationRecipe.js b/src/components/FermentationRecipeList/FermentationRecipe.js
new file mode 100644
index 000000000..771fe394c
--- /dev/null
+++ b/src/components/FermentationRecipeList/FermentationRecipe.js
@@ -0,0 +1,169 @@
+/**
+ * @typedef {import("../../index").farmhand.item} item
+ * @typedef {import("../../index").farmhand.keg} keg
+ */
+
+import React, { useContext, useEffect, useState } from 'react'
+import { object } from 'prop-types'
+import Card from '@material-ui/core/Card'
+import CardHeader from '@material-ui/core/CardHeader'
+import CardActions from '@material-ui/core/CardActions'
+import Button from '@material-ui/core/Button'
+
+import { PURCHASEABLE_CELLARS } from '../../constants'
+import { items } from '../../img'
+import { doesCellarSpaceRemain } from '../../utils/doesCellarSpaceRemain'
+import { getMaxYieldOfFermentationRecipe } from '../../utils/getMaxYieldOfFermentationRecipe'
+import { getSaltRequirementsForFermentationRecipe } from '../../utils/getSaltRequirementsForFermentationRecipe'
+import { FERMENTED_CROP_NAME } from '../../templates'
+import QuantityInput from '../QuantityInput'
+import FarmhandContext from '../Farmhand/Farmhand.context'
+import { fermentableItemsMap, itemsMap } from '../../data/maps'
+
+import './FermentationRecipe.sass'
+import { getInventoryQuantityMap } from '../../utils/getInventoryQuantityMap'
+import { integerString } from '../../utils'
+import AnimatedNumber from '../AnimatedNumber'
+import { memoize } from '../../utils/memoize'
+
+/**
+ * @type {function(keg[], item):number}
+ */
+const getRecipesInstancesInCellar = memoize(
+ /**
+ * @param {keg[]} cellarInventory
+ * @param {item} item
+ * @returns number
+ */
+ (cellarInventory, item) => {
+ return cellarInventory.filter(keg => keg.itemId === item.id).length
+ },
+ { cacheSize: Object.keys(fermentableItemsMap).length }
+)
+
+/**
+ * @param {Object} props
+ * @param {item} props.item
+ */
+export const FermentationRecipe = ({ item }) => {
+ /**
+ * @type {{
+ * gameState: {
+ * inventory: Array.- ,
+ * cellarInventory: Array.,
+ * purchasedCellar: number
+ * },
+ * handlers: {
+ * handleMakeFermentationRecipeClick: function(item, number)
+ * }
+ * }}
+ */
+ const {
+ gameState: { inventory, cellarInventory, purchasedCellar },
+ handlers: { handleMakeFermentationRecipeClick },
+ } = useContext(FarmhandContext)
+
+ const [quantity, setQuantity] = useState(1)
+
+ const inventoryQuantityMap = getInventoryQuantityMap(inventory)
+ const fermentationRecipeName = FERMENTED_CROP_NAME`${item}`
+ const { space: cellarSize } = PURCHASEABLE_CELLARS.get(purchasedCellar)
+
+ useEffect(() => {
+ setQuantity(
+ Math.min(
+ getMaxYieldOfFermentationRecipe(
+ item,
+ inventory,
+ cellarInventory,
+ cellarSize
+ ),
+ Math.max(1, quantity)
+ )
+ )
+ }, [cellarInventory, cellarSize, inventory, item, quantity])
+
+ const canBeMade =
+ quantity > 0 && doesCellarSpaceRemain(cellarInventory, purchasedCellar)
+
+ const handleMakeFermentationRecipe = () => {
+ if (canBeMade) {
+ handleMakeFermentationRecipeClick(item, quantity)
+ }
+ }
+
+ const maxQuantity = getMaxYieldOfFermentationRecipe(
+ item,
+ inventory,
+ cellarInventory,
+ cellarSize
+ )
+
+ const recipeInstancesInCellar = getRecipesInstancesInCellar(
+ cellarInventory,
+ item
+ )
+
+ return (
+
+
+ }
+ subheader={
+ <>
+
Days to ferment: {item.daysToFerment}
+
+ Units of {itemsMap[item.id].name} in inventory:{' '}
+ {integerString(inventoryQuantityMap[item.id] ?? 0)}
+
+
+ Units of {itemsMap.salt.name} required:{' '}
+ {getSaltRequirementsForFermentationRecipe(item)} (available:{' '}
+
+ )
+
+ In cellar: {integerString(recipeInstancesInCellar ?? 0)}
+ >
+ }
+ >
+
+
+
+
+
+ )
+}
+
+FermentationRecipe.propTypes = {
+ item: object.isRequired,
+}
diff --git a/src/components/FermentationRecipeList/FermentationRecipe.sass b/src/components/FermentationRecipeList/FermentationRecipe.sass
new file mode 100644
index 000000000..d5308696f
--- /dev/null
+++ b/src/components/FermentationRecipeList/FermentationRecipe.sass
@@ -0,0 +1,7 @@
+.FermentationRecipe
+ position: relative
+
+ .MuiCardHeader-avatar
+ img
+ width: 3em
+ filter: drop-shadow(1px 1px 2px rgba(255, 0, 131, 0.63))
diff --git a/src/components/FermentationRecipeList/FermentationRecipeList.js b/src/components/FermentationRecipeList/FermentationRecipeList.js
new file mode 100644
index 000000000..37455ff43
--- /dev/null
+++ b/src/components/FermentationRecipeList/FermentationRecipeList.js
@@ -0,0 +1,38 @@
+import React, { useContext } from 'react'
+
+import { fermentableItemsMap } from '../../data/maps'
+import FarmhandContext from '../Farmhand/Farmhand.context'
+import { getCropsAvailableToFerment } from '../../utils/getCropsAvailableToFerment'
+
+import { FermentationRecipe } from './FermentationRecipe'
+
+const totalFermentableItems = Object.keys(fermentableItemsMap).length
+
+export const FermentationRecipeList = () => {
+ const {
+ gameState: { levelEntitlements },
+ } = useContext(FarmhandContext)
+
+ const cropsAvailableToFerment = getCropsAvailableToFerment(levelEntitlements)
+
+ const numberOfCropsAvailableToFerment = Object.keys(cropsAvailableToFerment)
+ .length
+
+ return (
+ <>
+
+ Available Fermentation Recipes ({numberOfCropsAvailableToFerment} /{' '}
+ {totalFermentableItems})
+
+
+ {cropsAvailableToFerment.map(item => (
+ -
+
+
+ ))}
+
+ >
+ )
+}
+
+FermentationRecipeList.propTypes = {}
diff --git a/src/components/FermentationRecipeList/FermentationRecipeList.test.js b/src/components/FermentationRecipeList/FermentationRecipeList.test.js
new file mode 100644
index 000000000..7ac03ff2b
--- /dev/null
+++ b/src/components/FermentationRecipeList/FermentationRecipeList.test.js
@@ -0,0 +1,65 @@
+/**
+ * @typedef {import('../../index').farmhand.levelEntitlements} levelEntitlements
+ */
+import React from 'react'
+import { screen } from '@testing-library/dom'
+import { render } from '@testing-library/react'
+
+import FarmhandContext from '../Farmhand/Farmhand.context'
+import { getLevelEntitlements } from '../../utils/getLevelEntitlements'
+import { getCropsAvailableToFerment } from '../../utils/getCropsAvailableToFerment'
+import { fermentableItemsMap } from '../../data/maps'
+
+import { FermentationRecipeList } from './FermentationRecipeList'
+
+const totalFermentableItems = Object.keys(fermentableItemsMap).length
+
+jest.mock('./FermentationRecipe', () => ({
+ FermentationRecipe: () => <>>,
+}))
+
+/**
+ * @param {Object} props
+ * @param {levelEntitlements} props.levelEntitlements
+ */
+const FermentationRecipeListStub = ({ levelEntitlements } = {}) => (
+
+
+
+)
+
+describe('FermentationRecipeList', () => {
+ test('displays unlearned recipes', () => {
+ const levelEntitlements = getLevelEntitlements(0)
+
+ render()
+ const header = screen.getByText(
+ `Available Fermentation Recipes (0 / ${totalFermentableItems})`
+ )
+
+ expect(header).toBeInTheDocument()
+ })
+
+ test('displays available recipes', () => {
+ const levelEntitlements = getLevelEntitlements(100)
+
+ render()
+
+ const cropsAvailableToFerment = getCropsAvailableToFerment(
+ levelEntitlements
+ )
+
+ const header = screen.getByText(
+ `Available Fermentation Recipes (${cropsAvailableToFerment.length} / ${totalFermentableItems})`
+ )
+
+ expect(header).toBeInTheDocument()
+ })
+})
diff --git a/src/components/Field/Field.js b/src/components/Field/Field.js
index bc71757ef..f4c8dca7c 100644
--- a/src/components/Field/Field.js
+++ b/src/components/Field/Field.js
@@ -21,10 +21,10 @@ import tools from '../../data/tools'
import {
doesInventorySpaceRemain,
farmProductsSold,
- getLevelEntitlements,
levelAchieved,
nullArray,
} from '../../utils'
+import { getLevelEntitlements } from '../../utils/getLevelEntitlements'
import './Field.sass'
diff --git a/src/components/Home/Home.js b/src/components/Home/Home.js
index dcf34d97a..fac6073d4 100644
--- a/src/components/Home/Home.js
+++ b/src/components/Home/Home.js
@@ -18,7 +18,8 @@ import { achievementsMap } from '../../data/achievements'
import FarmhandContext from '../Farmhand/Farmhand.context'
import { STANDARD_LOAN_AMOUNT } from '../../constants'
import { stageFocusType } from '../../enums'
-import { isDecember, memoize } from '../../utils'
+import { isDecember } from '../../utils'
+import { memoize } from '../../utils/memoize'
import Achievement from '../Achievement'
import { SnowBackground } from './SnowBackground'
diff --git a/src/components/Item/Item.js b/src/components/Item/Item.js
index 40aa9ceb4..3d5886d9e 100644
--- a/src/components/Item/Item.js
+++ b/src/components/Item/Item.js
@@ -19,13 +19,13 @@ import { moneyString } from '../../utils/moneyString'
import {
inventorySpaceRemaining,
isItemSoldInShop,
- getCropLifecycleDuration,
getFinalCropItemFromSeedItem,
getItemCurrentValue,
getResaleValue,
getSalePriceMultiplier,
integerString,
} from '../../utils'
+import { getCropLifecycleDuration } from '../../utils/getCropLifecycleDuration'
import QuantityInput from '../QuantityInput'
import AnimatedNumber from '../AnimatedNumber'
diff --git a/src/components/Plot/Plot.js b/src/components/Plot/Plot.js
index 65ea201bd..9d3c23ca2 100644
--- a/src/components/Plot/Plot.js
+++ b/src/components/Plot/Plot.js
@@ -8,12 +8,8 @@ import Typography from '@material-ui/core/Typography'
import classNames from 'classnames'
import FarmhandContext from '../Farmhand/Farmhand.context'
-import {
- getCropLifeStage,
- getCropLifecycleDuration,
- getPlotContentType,
- getPlotImage,
-} from '../../utils'
+import { getCropLifeStage, getPlotContentType, getPlotImage } from '../../utils'
+import { getCropLifecycleDuration } from '../../utils/getCropLifecycleDuration'
import { itemsMap, cropItemIdToSeedItemMap } from '../../data/maps'
import { pixel, plotStates } from '../../img'
import { cropLifeStage, fertilizerType, itemType } from '../../enums'
diff --git a/src/components/QuantityInput/QuantityInput.js b/src/components/QuantityInput/QuantityInput.js
index 1660354df..e3360403f 100644
--- a/src/components/QuantityInput/QuantityInput.js
+++ b/src/components/QuantityInput/QuantityInput.js
@@ -26,6 +26,8 @@ const QuantityNumberFormat = ({ inputRef, min, max, onChange, ...rest }) => (
/>
)
+// TODO: Rename event handlers to use on* format
+// https://github.com/jeremyckahn/farmhand/issues/414
const QuantityTextInput = ({
handleSubmit,
handleUpdateNumber,
diff --git a/src/components/Shop/Shop.js b/src/components/Shop/Shop.js
index b4c334ee6..40ff3bc8f 100644
--- a/src/components/Shop/Shop.js
+++ b/src/components/Shop/Shop.js
@@ -17,8 +17,8 @@ import {
dollarString,
getCostOfNextStorageExpansion,
integerString,
- memoize,
} from '../../utils'
+import { memoize } from '../../utils/memoize'
import { items } from '../../img'
import { itemType, toolType } from '../../enums'
import {
diff --git a/src/components/Toolbelt/Toolbelt.js b/src/components/Toolbelt/Toolbelt.js
index a32a9dee1..1b44fc6ef 100644
--- a/src/components/Toolbelt/Toolbelt.js
+++ b/src/components/Toolbelt/Toolbelt.js
@@ -7,9 +7,7 @@ import Button from '@material-ui/core/Button'
import Tooltip from '@material-ui/core/Tooltip'
import { toolLevel } from '../../enums'
-
-import { memoize } from '../../utils'
-
+import { memoize } from '../../utils/memoize'
import FarmhandContext from '../Farmhand/Farmhand.context'
import toolsData from '../../data/tools'
diff --git a/src/components/Toolbelt/Toolbelt.test.js b/src/components/Toolbelt/Toolbelt.test.js
index 7b23cb86d..b9694cdfa 100644
--- a/src/components/Toolbelt/Toolbelt.test.js
+++ b/src/components/Toolbelt/Toolbelt.test.js
@@ -5,8 +5,7 @@ import { fieldMode, toolLevel, toolType } from '../../enums'
import { Toolbelt } from './Toolbelt'
-jest.mock('../../utils', () => ({
- ...jest.requireActual('../../utils'),
+jest.mock('../../utils/memoize', () => ({
memoize: jest.fn(callback => {
return (...args) => {
return callback(...args)
diff --git a/src/constants.js b/src/constants.js
index adfeb13c0..7e1fc786e 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -133,6 +133,7 @@ export const I_AM_RICH_BONUSES = [0.05, 0.1, 0.25]
export const PERSISTED_STATE_KEYS = [
'allowCustomPeerCowNames',
+ 'cellarInventory',
'completedAchievements',
'cowBreedingPen',
'cowColorsPurchased',
@@ -153,6 +154,7 @@ export const PERSISTED_STATE_KEYS = [
'inventoryLimit',
'isCombineEnabled',
'itemsSold',
+ 'cellarItemsSold',
'learnedRecipes',
'loanBalance',
'loansTakenOut',
@@ -250,3 +252,5 @@ export const COW_COLORS_HEX_MAP = {
export const COW_TRADE_TIMEOUT = 10000
export const WEEDS_SPAWN_CHANCE = 0.15
+
+export const KEG_INTEREST_RATE = 0.02
diff --git a/src/data/__mocks__/maps.js b/src/data/__mocks__/maps.js
index 0da2ab83b..effa3c294 100644
--- a/src/data/__mocks__/maps.js
+++ b/src/data/__mocks__/maps.js
@@ -28,9 +28,11 @@ export const itemsMap = {
...recipesMap,
}
-export const cropItemIdToSeedItemMap = jest.requireActual('../maps')
- .cropItemIdToSeedItemMap
-
export const cropTypeToIdMap = {
SAMPLE_CROP_TYPE_1: 'sample-crop-type-1',
}
+
+export const {
+ cropItemIdToSeedItemMap,
+ fermentableItemsMap,
+} = jest.requireActual('../maps')
diff --git a/src/data/achievements.js b/src/data/achievements.js
index eb9c44aee..1b19c7d88 100644
--- a/src/data/achievements.js
+++ b/src/data/achievements.js
@@ -7,10 +7,10 @@ import {
getProfitRecord,
integerString,
isOctober,
- memoize,
moneyTotal,
percentageString,
} from '../utils'
+import { memoize } from '../utils/memoize'
import { cropLifeStage, standardCowColors } from '../enums'
import { COW_FEED_ITEM_ID, I_AM_RICH_BONUSES } from '../constants'
diff --git a/src/data/crop.js b/src/data/crop.js
index 4fa91eff9..140adc575 100644
--- a/src/data/crop.js
+++ b/src/data/crop.js
@@ -1,6 +1,7 @@
/** @typedef {import("../index").farmhand.item} farmhand.item */
import { fieldMode, itemType } from '../enums'
+import { getCropLifecycleDuration } from '../utils/getCropLifecycleDuration'
const { freeze } = Object
@@ -14,10 +15,7 @@ export const crop = ({
tier,
isSeed = Boolean(growsInto),
- cropLifecycleDuration = Object.values(cropTimetable).reduce(
- (acc, value) => acc + value,
- 0
- ),
+ cropLifecycleDuration = getCropLifecycleDuration({ cropTimetable }),
...rest
}) =>
@@ -37,12 +35,14 @@ export const crop = ({
/**
* @param {farmhand.item} item
- * @param {number} [variantIdx]
+ * @param {Object} [config]
+ * @param {number} [config.variantIdx]
+ * @param {boolean} [config.canBeFermented]
* @returns {farmhand.item}
*/
export const fromSeed = (
{ cropTimetable, cropType, growsInto, tier },
- variantIdx = 0
+ { variantIdx = 0, canBeFermented = false } = {}
) => {
const variants = Array.isArray(growsInto) ? growsInto : [growsInto]
@@ -53,5 +53,8 @@ export const fromSeed = (
id: variants[variantIdx],
tier,
type: itemType.CROP,
+ ...(canBeFermented && {
+ daysToFerment: getCropLifecycleDuration({ cropTimetable }) * tier,
+ }),
}
}
diff --git a/src/data/crops/asparagus.js b/src/data/crops/asparagus.js
index dee18c45b..2fc8ea2ea 100644
--- a/src/data/crops/asparagus.js
+++ b/src/data/crops/asparagus.js
@@ -24,6 +24,6 @@ export const asparagusSeed = crop({
* @type {farmhand.item}
*/
export const asparagus = crop({
- ...fromSeed(asparagusSeed),
+ ...fromSeed(asparagusSeed, { canBeFermented: true }),
name: 'Asparagus',
})
diff --git a/src/data/crops/carrot.js b/src/data/crops/carrot.js
index 8a0658912..f2a543503 100644
--- a/src/data/crops/carrot.js
+++ b/src/data/crops/carrot.js
@@ -24,6 +24,6 @@ export const carrotSeed = crop({
* @type {farmhand.item}
*/
export const carrot = crop({
- ...fromSeed(carrotSeed),
+ ...fromSeed(carrotSeed, { canBeFermented: true }),
name: 'Carrot',
})
diff --git a/src/data/crops/corn.js b/src/data/crops/corn.js
index bd5b57406..d8f9b1b52 100644
--- a/src/data/crops/corn.js
+++ b/src/data/crops/corn.js
@@ -24,6 +24,6 @@ export const cornSeed = crop({
* @type {farmhand.item}
*/
export const corn = crop({
- ...fromSeed(cornSeed),
+ ...fromSeed(cornSeed, { canBeFermented: true }),
name: 'Corn',
})
diff --git a/src/data/crops/garlic.js b/src/data/crops/garlic.js
index 06639196a..3a4a05809 100644
--- a/src/data/crops/garlic.js
+++ b/src/data/crops/garlic.js
@@ -24,6 +24,6 @@ export const garlicSeed = crop({
* @type {farmhand.item}
*/
export const garlic = crop({
- ...fromSeed(garlicSeed),
+ ...fromSeed(garlicSeed, { canBeFermented: true }),
name: 'Garlic',
})
diff --git a/src/data/crops/grape.js b/src/data/crops/grape.js
index a69ebfdfd..dda086e11 100644
--- a/src/data/crops/grape.js
+++ b/src/data/crops/grape.js
@@ -38,7 +38,9 @@ export const grapeSeed = crop({
* @type {farmhand.cropVariety}
*/
export const grapeChardonnay = crop({
- ...fromSeed(grapeSeed, grapeSeed.growsInto.indexOf('grape-chardonnay')),
+ ...fromSeed(grapeSeed, {
+ variantIdx: grapeSeed.growsInto.indexOf('grape-chardonnay'),
+ }),
name: 'Chardonnay Grape',
imageId: 'grape-green',
})
@@ -48,7 +50,9 @@ export const grapeChardonnay = crop({
* @type {farmhand.cropVariety}
*/
export const grapeSauvignonBlanc = crop({
- ...fromSeed(grapeSeed, grapeSeed.growsInto.indexOf('grape-sauvignon-blanc')),
+ ...fromSeed(grapeSeed, {
+ variantIdx: grapeSeed.growsInto.indexOf('grape-sauvignon-blanc'),
+ }),
name: 'Sauvignon Blanc Grape',
imageId: 'grape-green',
})
@@ -58,7 +62,7 @@ export const grapeSauvignonBlanc = crop({
* @type {farmhand.cropVariety}
*/
// export const grapePinotBlanc = crop({
-// ...fromSeed(grapeSeed, grapeSeed.growsInto.indexOf('grape-pinot-blanc')),
+// ...fromSeed(grapeSeed, { variantIdx: grapeSeed.growsInto.indexOf('grape-pinot-blanc') }),
// name: 'Pinot Blanc Grape',
// imageId: 'grape-green',
// })
@@ -68,7 +72,7 @@ export const grapeSauvignonBlanc = crop({
* @type {farmhand.cropVariety}
*/
// export const grapeMuscat = crop({
-// ...fromSeed(grapeSeed, grapeSeed.growsInto.indexOf('grape-muscat')),
+// ...fromSeed(grapeSeed, { variantIdx: grapeSeed.growsInto.indexOf('grape-muscat') }),
// name: 'Muscat Grape',
// imageId: 'grape-green',
// })
@@ -78,7 +82,7 @@ export const grapeSauvignonBlanc = crop({
* @type {farmhand.cropVariety}
*/
// export const grapeRiesling = crop({
-// ...fromSeed(grapeSeed, grapeSeed.growsInto.indexOf('grape-riesling')),
+// ...fromSeed(grapeSeed, { variantIdx: grapeSeed.growsInto.indexOf('grape-riesling') }),
// name: 'Riesling Grape',
// imageId: 'grape-green',
// })
@@ -88,7 +92,7 @@ export const grapeSauvignonBlanc = crop({
* @type {farmhand.cropVariety}
*/
// export const grapeMerlot = crop({
-// ...fromSeed(grapeSeed, grapeSeed.growsInto.indexOf('grape-merlot')),
+// ...fromSeed(grapeSeed, { variantIdx: grapeSeed.growsInto.indexOf('grape-merlot') }),
// name: 'Merlot Grape',
// imageId: 'grape-purple',
// })
@@ -98,10 +102,9 @@ export const grapeSauvignonBlanc = crop({
* @type {farmhand.cropVariety}
*/
export const grapeCabernetSauvignon = crop({
- ...fromSeed(
- grapeSeed,
- grapeSeed.growsInto.indexOf('grape-cabernet-sauvignon')
- ),
+ ...fromSeed(grapeSeed, {
+ variantIdx: grapeSeed.growsInto.indexOf('grape-cabernet-sauvignon'),
+ }),
name: 'Cabernet Sauvignon Grape',
imageId: 'grape-purple',
})
@@ -111,7 +114,7 @@ export const grapeCabernetSauvignon = crop({
* @type {farmhand.cropVariety}
*/
// export const grapeSyrah = crop({
-// ...fromSeed(grapeSeed, grapeSeed.growsInto.indexOf('grape-syrah')),
+// ...fromSeed(grapeSeed, { variantIdx: grapeSeed.growsInto.indexOf('grape-syrah') }),
// name: 'Syrah Grape',
// imageId: 'grape-purple',
// })
@@ -121,7 +124,9 @@ export const grapeCabernetSauvignon = crop({
* @type {farmhand.cropVariety}
*/
export const grapeTempranillo = crop({
- ...fromSeed(grapeSeed, grapeSeed.growsInto.indexOf('grape-tempranillo')),
+ ...fromSeed(grapeSeed, {
+ variantIdx: grapeSeed.growsInto.indexOf('grape-tempranillo'),
+ }),
name: 'Tempranillo Grape',
imageId: 'grape-purple',
})
@@ -131,7 +136,9 @@ export const grapeTempranillo = crop({
* @type {farmhand.cropVariety}
*/
export const grapeNebbiolo = crop({
- ...fromSeed(grapeSeed, grapeSeed.growsInto.indexOf('grape-nebbiolo')),
+ ...fromSeed(grapeSeed, {
+ variantIdx: grapeSeed.growsInto.indexOf('grape-nebbiolo'),
+ }),
name: 'Nebbiolo Grape',
imageId: 'grape-purple',
})
diff --git a/src/data/crops/jalapeno.js b/src/data/crops/jalapeno.js
index bcc1716ec..08c33f52d 100644
--- a/src/data/crops/jalapeno.js
+++ b/src/data/crops/jalapeno.js
@@ -24,6 +24,6 @@ export const jalapenoSeed = crop({
* @type {farmhand.item}
*/
export const jalapeno = crop({
- ...fromSeed(jalapenoSeed),
+ ...fromSeed(jalapenoSeed, { canBeFermented: true }),
name: 'Jalapeño',
})
diff --git a/src/data/crops/olive.js b/src/data/crops/olive.js
index 873dd56b0..a6cc312ce 100644
--- a/src/data/crops/olive.js
+++ b/src/data/crops/olive.js
@@ -24,6 +24,6 @@ export const oliveSeed = crop({
* @type {farmhand.item}
*/
export const olive = crop({
- ...fromSeed(oliveSeed),
+ ...fromSeed(oliveSeed, { canBeFermented: true }),
name: 'Olive',
})
diff --git a/src/data/crops/onion.js b/src/data/crops/onion.js
index 9e9a54401..d280936d9 100644
--- a/src/data/crops/onion.js
+++ b/src/data/crops/onion.js
@@ -24,6 +24,6 @@ export const onionSeed = crop({
* @type {farmhand.item}
*/
export const onion = crop({
- ...fromSeed(onionSeed),
+ ...fromSeed(onionSeed, { canBeFermented: true }),
name: 'Onion',
})
diff --git a/src/data/crops/pea.js b/src/data/crops/pea.js
index f097c5274..a56646967 100644
--- a/src/data/crops/pea.js
+++ b/src/data/crops/pea.js
@@ -24,6 +24,6 @@ export const peaSeed = crop({
* @type {farmhand.item}
*/
export const pea = crop({
- ...fromSeed(peaSeed),
+ ...fromSeed(peaSeed, { canBeFermented: true }),
name: 'Pea',
})
diff --git a/src/data/crops/potato.js b/src/data/crops/potato.js
index 45a5eecaa..01e2aadf0 100644
--- a/src/data/crops/potato.js
+++ b/src/data/crops/potato.js
@@ -24,6 +24,6 @@ export const potatoSeed = crop({
* @type {farmhand.item}
*/
export const potato = crop({
- ...fromSeed(potatoSeed),
+ ...fromSeed(potatoSeed, { canBeFermented: true }),
name: 'Potato',
})
diff --git a/src/data/crops/pumpkin.js b/src/data/crops/pumpkin.js
index 67f6cc30f..4799cadec 100644
--- a/src/data/crops/pumpkin.js
+++ b/src/data/crops/pumpkin.js
@@ -24,6 +24,6 @@ export const pumpkinSeed = crop({
* @type {farmhand.item}
*/
export const pumpkin = crop({
- ...fromSeed(pumpkinSeed),
+ ...fromSeed(pumpkinSeed, { canBeFermented: true }),
name: 'Pumpkin',
})
diff --git a/src/data/crops/soybean.js b/src/data/crops/soybean.js
index 4904d0566..12aab473c 100644
--- a/src/data/crops/soybean.js
+++ b/src/data/crops/soybean.js
@@ -24,6 +24,6 @@ export const soybeanSeed = crop({
* @type {farmhand.item}
*/
export const soybean = crop({
- ...fromSeed(soybeanSeed),
+ ...fromSeed(soybeanSeed, { canBeFermented: true }),
name: 'Soybean',
})
diff --git a/src/data/crops/spinach.js b/src/data/crops/spinach.js
index 485d3ee52..d25419e6d 100644
--- a/src/data/crops/spinach.js
+++ b/src/data/crops/spinach.js
@@ -24,6 +24,6 @@ export const spinachSeed = crop({
* @type {farmhand.item}
*/
export const spinach = crop({
- ...fromSeed(spinachSeed),
+ ...fromSeed(spinachSeed, { canBeFermented: true }),
name: 'Spinach',
})
diff --git a/src/data/crops/sunflower.js b/src/data/crops/sunflower.js
index 37276255d..b0b4828fc 100644
--- a/src/data/crops/sunflower.js
+++ b/src/data/crops/sunflower.js
@@ -24,6 +24,6 @@ export const sunflowerSeed = crop({
* @type {farmhand.item}
*/
export const sunflower = crop({
- ...fromSeed(sunflowerSeed),
+ ...fromSeed(sunflowerSeed, { canBeFermented: true }),
name: 'Sunflower',
})
diff --git a/src/data/crops/sweet-potato.js b/src/data/crops/sweet-potato.js
index 821727543..c3c80e51a 100644
--- a/src/data/crops/sweet-potato.js
+++ b/src/data/crops/sweet-potato.js
@@ -24,6 +24,6 @@ export const sweetPotatoSeed = crop({
* @type {farmhand.item}
*/
export const sweetPotato = crop({
- ...fromSeed(sweetPotatoSeed),
+ ...fromSeed(sweetPotatoSeed, { canBeFermented: true }),
name: 'Sweet Potato',
})
diff --git a/src/data/crops/tomato.js b/src/data/crops/tomato.js
index ee16fed7e..bd52de735 100644
--- a/src/data/crops/tomato.js
+++ b/src/data/crops/tomato.js
@@ -24,6 +24,6 @@ export const tomatoSeed = crop({
* @type {farmhand.item}
*/
export const tomato = crop({
- ...fromSeed(tomatoSeed),
+ ...fromSeed(tomatoSeed, { canBeFermented: true }),
name: 'Tomato',
})
diff --git a/src/data/items-map.js b/src/data/items-map.js
index 7c362f77f..6762ccddc 100644
--- a/src/data/items-map.js
+++ b/src/data/items-map.js
@@ -3,9 +3,12 @@
import * as items from '../data/items'
/**
- * ⚠️ This is a low-level object that is UNSAFE to use directly outside of
+ * ⚠️⚠️⚠️ This is a low-level object that is UNSAFE to use directly outside of
* initial bootup. Use itemsMap in src/data/maps.js instead.
* @type {Object.}
+ * @deprecated This is not actually deprecated. It's just marked as such to
+ * make it more obvious during development that this should generally not be
+ * used directly.
*/
export default {
...Object.keys(items).reduce((acc, itemName) => {
diff --git a/src/data/maps.js b/src/data/maps.js
index 6d1ba67c6..cf0bd7ab1 100644
--- a/src/data/maps.js
+++ b/src/data/maps.js
@@ -64,6 +64,17 @@ export const itemsMap = {
...upgradesMap,
}
+/**
+ * @type {Object.}
+ */
+export const fermentableItemsMap = Object.fromEntries(
+ Object.entries(itemsMap).filter(([itemId]) => {
+ const item = itemsMap[itemId]
+
+ return 'daysToFerment' in item
+ })
+)
+
/**
* @type {Object.}
*/
diff --git a/src/game-logic/reducers/addKegToCellarInventory.js b/src/game-logic/reducers/addKegToCellarInventory.js
new file mode 100644
index 000000000..7364b7843
--- /dev/null
+++ b/src/game-logic/reducers/addKegToCellarInventory.js
@@ -0,0 +1,18 @@
+/** @typedef {import("../../index").farmhand.keg} keg */
+/** @typedef {import("../../components/Farmhand/Farmhand").farmhand.state} state */
+
+/**
+ * ⚠️ It is the responsibility of the consumer of this reducer to ensure that
+ * there is sufficient space in cellarInventory.
+ * @param {state} state
+ * @param {keg} keg
+ * @returns {state}
+ */
+export const addKegToCellarInventory = (state, keg) => {
+ const { cellarInventory } = state
+
+ return {
+ ...state,
+ cellarInventory: [...cellarInventory, keg],
+ }
+}
diff --git a/src/game-logic/reducers/applyPrecipitation.js b/src/game-logic/reducers/applyPrecipitation.js
index 3b3d1da27..854b3120f 100644
--- a/src/game-logic/reducers/applyPrecipitation.js
+++ b/src/game-logic/reducers/applyPrecipitation.js
@@ -1,5 +1,5 @@
import { fertilizerType } from '../../enums'
-import { getInventoryQuantityMap } from '../../utils'
+import { getInventoryQuantityMap } from '../../utils/getInventoryQuantityMap'
import {
RAIN_MESSAGE,
STORM_MESSAGE,
diff --git a/src/game-logic/reducers/computeStateForNextDay.js b/src/game-logic/reducers/computeStateForNextDay.js
index d039f5d0b..5b12cb2c0 100644
--- a/src/game-logic/reducers/computeStateForNextDay.js
+++ b/src/game-logic/reducers/computeStateForNextDay.js
@@ -1,22 +1,23 @@
import { generateCow } from '../../utils'
import { generateValueAdjustments } from '../../common/utils'
-import { processWeather } from './processWeather'
-import { processField } from './processField'
-import { processSprinklers } from './processSprinklers'
-import { processNerfs } from './processNerfs'
-import { processFeedingCows } from './processFeedingCows'
+import { applyLoanInterest } from './applyLoanInterest'
+import { computeCowInventoryForNextDay } from './computeCowInventoryForNextDay'
+import { generatePriceEvents } from './generatePriceEvents'
+import { processCellar } from './processCellar'
import { processCowAttrition } from './processCowAttrition'
-import { processMilkingCows } from './processMilkingCows'
-import { processCowFertilizerProduction } from './processCowFertilizerProduction'
import { processCowBreeding } from './processCowBreeding'
-import { computeCowInventoryForNextDay } from './computeCowInventoryForNextDay'
+import { processCowFertilizerProduction } from './processCowFertilizerProduction'
+import { processFeedingCows } from './processFeedingCows'
+import { processField } from './processField'
+import { processMilkingCows } from './processMilkingCows'
+import { processNerfs } from './processNerfs'
+import { processSprinklers } from './processSprinklers'
+import { processWeather } from './processWeather'
import { rotateNotificationLogs } from './rotateNotificationLogs'
-import { generatePriceEvents } from './generatePriceEvents'
-import { updatePriceEvents } from './updatePriceEvents'
import { updateFinancialRecords } from './updateFinancialRecords'
import { updateInventoryRecordsForNextDay } from './updateInventoryRecordsForNextDay'
-import { applyLoanInterest } from './applyLoanInterest'
+import { updatePriceEvents } from './updatePriceEvents'
/**
* @param {farmhand.state} state
@@ -49,6 +50,7 @@ export const computeStateForNextDay = (state, isFirstDay = false) =>
processCowAttrition,
processMilkingCows,
processCowFertilizerProduction,
+ processCellar,
updatePriceEvents,
updateFinancialRecords,
updateInventoryRecordsForNextDay,
diff --git a/src/game-logic/reducers/computeStateForNextDay.test.js b/src/game-logic/reducers/computeStateForNextDay.test.js
index c4cd6b562..b90efa69a 100644
--- a/src/game-logic/reducers/computeStateForNextDay.test.js
+++ b/src/game-logic/reducers/computeStateForNextDay.test.js
@@ -28,6 +28,7 @@ describe('computeStateForNextDay', () => {
}),
],
],
+ cellarInventory: [],
cowInventory: [],
historicalDailyLosses: [],
historicalDailyRevenue: [],
diff --git a/src/game-logic/reducers/decrementItemFromInventory.js b/src/game-logic/reducers/decrementItemFromInventory.js
index e30edd776..72f92d678 100644
--- a/src/game-logic/reducers/decrementItemFromInventory.js
+++ b/src/game-logic/reducers/decrementItemFromInventory.js
@@ -1,8 +1,10 @@
+/** @typedef {import("../../components/Farmhand/Farmhand").farmhand.state} state */
+
/**
- * @param {farmhand.state} state
+ * @param {state} state
* @param {string} itemId
* @param {number} [howMany=1]
- * @returns {farmhand.state}
+ * @returns {state}
*/
export const decrementItemFromInventory = (state, itemId, howMany = 1) => {
const inventory = [...state.inventory]
diff --git a/src/game-logic/reducers/generatePriceEvents.js b/src/game-logic/reducers/generatePriceEvents.js
index bfb5807c0..de02d3a4a 100644
--- a/src/game-logic/reducers/generatePriceEvents.js
+++ b/src/game-logic/reducers/generatePriceEvents.js
@@ -1,11 +1,11 @@
import {
farmProductsSold,
filterItemIdsToSeeds,
- getLevelEntitlements,
getPriceEventForCrop,
getRandomUnlockedCrop,
levelAchieved,
} from '../../utils'
+import { getLevelEntitlements } from '../../utils/getLevelEntitlements'
import { PRICE_EVENT_CHANCE } from '../../constants'
import { PRICE_CRASH, PRICE_SURGE } from '../../templates'
import { random } from '../../common/utils'
diff --git a/src/game-logic/reducers/harvestPlot.js b/src/game-logic/reducers/harvestPlot.js
index a723b6166..f80e0b375 100644
--- a/src/game-logic/reducers/harvestPlot.js
+++ b/src/game-logic/reducers/harvestPlot.js
@@ -9,10 +9,10 @@ import { itemsMap } from '../../data/maps'
import {
doesInventorySpaceRemain,
getCropLifeStage,
- getInventoryQuantityMap,
getPlotContentType,
getSeedItemIdFromFinalStageCropItemId,
} from '../../utils'
+import { getInventoryQuantityMap } from '../../utils/getInventoryQuantityMap'
import { addItemToInventory } from './addItemToInventory'
import { modifyFieldPlotAt } from './modifyFieldPlotAt'
diff --git a/src/game-logic/reducers/index.js b/src/game-logic/reducers/index.js
index 19babd7c0..7053daec3 100644
--- a/src/game-logic/reducers/index.js
+++ b/src/game-logic/reducers/index.js
@@ -24,6 +24,7 @@ export * from './harvestPlot'
export * from './hugCow'
export * from './incrementPlotContentAge'
export * from './makeRecipe'
+export * from './makeFermentationRecipe'
export * from './minePlot'
export * from './modifyCow'
export * from './modifyFieldPlotAt'
@@ -51,11 +52,13 @@ export * from './purchaseSmelter'
export * from './purchaseStorageExpansion'
export * from './removeCowFromInventory'
export * from './removeFieldPlotAt'
+export * from './removeKegFromCellar'
export * from './removePeer'
export * from './rotateNotificationLogs'
export * from './selectCow'
export * from './sellCow'
export * from './sellItem'
+export * from './sellKeg'
export * from './setScarecrow'
export * from './setSprinkler'
export * from './showNotification'
diff --git a/src/game-logic/reducers/makeFermentationRecipe.js b/src/game-logic/reducers/makeFermentationRecipe.js
new file mode 100644
index 000000000..748134bc8
--- /dev/null
+++ b/src/game-logic/reducers/makeFermentationRecipe.js
@@ -0,0 +1,67 @@
+/**
+ * @typedef {import("../../index").farmhand.item} item
+ * @typedef {import("../../index").farmhand.keg} keg
+ * @typedef {import("../../components/Farmhand/Farmhand").farmhand.state} state
+ */
+
+import { v4 as uuid } from 'uuid'
+
+import { PURCHASEABLE_CELLARS } from '../../constants'
+import { itemsMap } from '../../data/maps'
+import { getSaltRequirementsForFermentationRecipe } from '../../utils/getSaltRequirementsForFermentationRecipe'
+import { getMaxYieldOfFermentationRecipe } from '../../utils/getMaxYieldOfFermentationRecipe'
+
+import { addKegToCellarInventory } from './addKegToCellarInventory'
+import { decrementItemFromInventory } from './decrementItemFromInventory'
+
+/**
+ * @param {state} state
+ * @param {item} fermentationRecipe
+ * @param {number} [howMany=1]
+ * @returns {farmhand.state}
+ */
+export const makeFermentationRecipe = (
+ state,
+ fermentationRecipe,
+ howMany = 1
+) => {
+ const { inventory, cellarInventory, purchasedCellar } = state
+
+ const { space: cellarSize } = PURCHASEABLE_CELLARS.get(purchasedCellar)
+
+ const maxYield = getMaxYieldOfFermentationRecipe(
+ fermentationRecipe,
+ inventory,
+ cellarInventory,
+ cellarSize
+ )
+
+ if (maxYield < howMany) {
+ return state
+ }
+
+ for (let i = 0; i < howMany; i++) {
+ /** @type keg */
+ const keg = {
+ id: uuid(),
+ itemId: fermentationRecipe.id,
+ daysUntilMature: fermentationRecipe.daysToFerment,
+ }
+
+ state = addKegToCellarInventory(state, keg)
+ }
+
+ const saltRequirements = getSaltRequirementsForFermentationRecipe(
+ fermentationRecipe
+ )
+
+ state = decrementItemFromInventory(state, fermentationRecipe.id, howMany)
+
+ state = decrementItemFromInventory(
+ state,
+ itemsMap.salt.id,
+ howMany * saltRequirements
+ )
+
+ return state
+}
diff --git a/src/game-logic/reducers/makeFermentationRecipe.test.js b/src/game-logic/reducers/makeFermentationRecipe.test.js
new file mode 100644
index 000000000..cb31765c8
--- /dev/null
+++ b/src/game-logic/reducers/makeFermentationRecipe.test.js
@@ -0,0 +1,100 @@
+import { carrot } from '../../data/crops'
+import { salt } from '../../data/recipes'
+
+import { makeFermentationRecipe } from './makeFermentationRecipe'
+
+describe('makeFermentationRecipe', () => {
+ test.each([
+ // Insufficient salt inventory (no-op)
+ [
+ {
+ inventory: [{ id: carrot.id, quantity: 1 }],
+ cellarInventory: [],
+ purchasedCellar: 1,
+ },
+ carrot,
+ 1,
+ {
+ inventory: [{ id: carrot.id, quantity: 1 }],
+ cellarInventory: [],
+ purchasedCellar: 1,
+ },
+ ],
+
+ // Supplies for one keg
+ [
+ {
+ inventory: [
+ { id: carrot.id, quantity: 2 },
+ { id: salt.id, quantity: 4 },
+ ],
+ cellarInventory: [],
+ purchasedCellar: 1,
+ },
+ carrot,
+ 1,
+ {
+ inventory: [{ id: carrot.id, quantity: 1 }],
+ cellarInventory: [
+ { daysUntilMature: 5, itemId: carrot.id, id: expect.toBeString() },
+ ],
+ purchasedCellar: 1,
+ },
+ ],
+
+ // Supplies for multiple kegs
+ [
+ {
+ inventory: [
+ { id: carrot.id, quantity: 2 },
+ { id: salt.id, quantity: 9 },
+ ],
+ cellarInventory: [],
+ purchasedCellar: 1,
+ },
+ carrot,
+ 2,
+ {
+ inventory: [{ id: salt.id, quantity: 1 }],
+ cellarInventory: [
+ { daysUntilMature: 5, itemId: carrot.id, id: expect.toBeString() },
+ { daysUntilMature: 5, itemId: carrot.id, id: expect.toBeString() },
+ ],
+ purchasedCellar: 1,
+ },
+ ],
+
+ // Requesting higher yield than there is salt available for (no-op)
+ [
+ {
+ inventory: [
+ { id: carrot.id, quantity: 2 },
+ { id: salt.id, quantity: 9 },
+ ],
+ cellarInventory: [],
+ purchasedCellar: 1,
+ },
+ carrot,
+ 10,
+ {
+ inventory: [
+ { id: carrot.id, quantity: 2 },
+ { id: salt.id, quantity: 9 },
+ ],
+ cellarInventory: [],
+ purchasedCellar: 1,
+ },
+ ],
+ ])(
+ 'kegs are produced according to input',
+ (inputState, fermentationRecipe, howMany, expectedState) => {
+ const state = makeFermentationRecipe(
+ inputState,
+ fermentationRecipe,
+ howMany
+ )
+
+ expect(state).toEqual(expectedState)
+ }
+ )
+})
diff --git a/src/game-logic/reducers/processCellar.js b/src/game-logic/reducers/processCellar.js
new file mode 100644
index 000000000..30575addc
--- /dev/null
+++ b/src/game-logic/reducers/processCellar.js
@@ -0,0 +1,24 @@
+/** @typedef {import("../../components/Farmhand/Farmhand").farmhand.state} state */
+
+/**
+ * @param {state} state
+ * @returns state
+ */
+export const processCellar = state => {
+ const { cellarInventory } = state
+
+ const newCellarInventory = [...cellarInventory]
+
+ for (let i = 0; i < newCellarInventory.length; i++) {
+ const keg = newCellarInventory[i]
+
+ newCellarInventory[i] = {
+ ...keg,
+ daysUntilMature: keg.daysUntilMature - 1,
+ }
+ }
+
+ state = { ...state, cellarInventory: newCellarInventory }
+
+ return state
+}
diff --git a/src/game-logic/reducers/processCellar.test.js b/src/game-logic/reducers/processCellar.test.js
new file mode 100644
index 000000000..c84a507d9
--- /dev/null
+++ b/src/game-logic/reducers/processCellar.test.js
@@ -0,0 +1,21 @@
+import { carrot, garlic } from '../../data/items'
+
+import { processCellar } from './processCellar'
+
+describe('processCellar', () => {
+ test('kegs are updated', () => {
+ const expectedState = processCellar({
+ cellarInventory: [
+ { itemId: carrot.id, daysUntilMature: 4, id: 'carrot-id' },
+ { itemId: garlic.id, daysUntilMature: 0, id: 'garlic-id' },
+ ],
+ })
+
+ expect(expectedState).toEqual({
+ cellarInventory: [
+ { itemId: carrot.id, daysUntilMature: 3, id: 'carrot-id' },
+ { itemId: garlic.id, daysUntilMature: -1, id: 'garlic-id' },
+ ],
+ })
+ })
+})
diff --git a/src/game-logic/reducers/processLevelUp.js b/src/game-logic/reducers/processLevelUp.js
index 095b08081..3bc37daef 100644
--- a/src/game-logic/reducers/processLevelUp.js
+++ b/src/game-logic/reducers/processLevelUp.js
@@ -1,12 +1,12 @@
import { levels } from '../../data/levels'
import {
farmProductsSold,
- getLevelEntitlements,
getRandomLevelUpReward,
getRandomLevelUpRewardQuantity,
levelAchieved,
unlockTool,
} from '../../utils'
+import { getLevelEntitlements } from '../../utils/getLevelEntitlements'
import { SPRINKLER_ITEM_ID } from '../../constants'
import { LEVEL_GAINED_NOTIFICATION } from '../../templates'
diff --git a/src/game-logic/reducers/processSprinklers.js b/src/game-logic/reducers/processSprinklers.js
index 924c76dac..92ff18ba2 100644
--- a/src/game-logic/reducers/processSprinklers.js
+++ b/src/game-logic/reducers/processSprinklers.js
@@ -1,11 +1,11 @@
import { itemType } from '../../enums'
import {
farmProductsSold,
- getLevelEntitlements,
getPlotContentType,
getRangeCoords,
levelAchieved,
} from '../../utils'
+import { getLevelEntitlements } from '../../utils/getLevelEntitlements'
import { setWasWatered } from './helpers'
import { modifyFieldPlotAt } from './modifyFieldPlotAt'
diff --git a/src/game-logic/reducers/removeKegFromCellar.js b/src/game-logic/reducers/removeKegFromCellar.js
new file mode 100644
index 000000000..0ac3b5622
--- /dev/null
+++ b/src/game-logic/reducers/removeKegFromCellar.js
@@ -0,0 +1,29 @@
+/**
+ * @typedef {import("../../components/Farmhand/Farmhand").farmhand.state} state
+ */
+
+/**
+ * @param {state} state
+ * @param {string} kegId
+ */
+export const removeKegFromCellar = (state, kegId) => {
+ const { cellarInventory } = state
+
+ const kegIdx = cellarInventory.findIndex(({ id }) => {
+ return id === kegId
+ })
+
+ if (kegIdx === -1) {
+ console.error(`removeKegFromCellar: kegId ${kegId} not found`)
+ return state
+ }
+
+ const newCellarInventory = [
+ ...cellarInventory.slice(0, kegIdx),
+ ...cellarInventory.slice(kegIdx + 1, cellarInventory.length),
+ ]
+
+ state = { ...state, cellarInventory: newCellarInventory }
+
+ return state
+}
diff --git a/src/game-logic/reducers/sellKeg.js b/src/game-logic/reducers/sellKeg.js
new file mode 100644
index 000000000..7cfcc04dc
--- /dev/null
+++ b/src/game-logic/reducers/sellKeg.js
@@ -0,0 +1,96 @@
+/**
+ * @typedef {import("../../index").farmhand.item} item
+ * @typedef {import("../../index").farmhand.keg} keg
+ * @typedef {import("../../components/Farmhand/Farmhand").farmhand.state} state
+ */
+
+import { itemsMap } from '../../data/maps'
+import {
+ castToMoney,
+ farmProductsSold,
+ getSalePriceMultiplier,
+ levelAchieved,
+ moneyTotal,
+} from '../../utils'
+import { LOAN_GARNISHMENT_RATE } from '../../constants'
+import { SOLD_FERMENTED_ITEM_PEER_NOTIFICATION } from '../../templates'
+import { getKegValue } from '../../utils/getKegValue'
+
+import { processLevelUp } from './processLevelUp'
+import { addRevenue } from './addRevenue'
+import { updateLearnedRecipes } from './updateLearnedRecipes'
+import { adjustLoan } from './adjustLoan'
+import { removeKegFromCellar } from './removeKegFromCellar'
+
+import { prependPendingPeerMessage } from './index'
+
+/**
+ * @param {state} state
+ * @param {keg} keg
+ * @returns {state}
+ */
+export const sellKeg = (state, keg) => {
+ const { itemId } = keg
+ const item = itemsMap[itemId]
+ const {
+ cellarItemsSold,
+ completedAchievements,
+ itemsSold,
+ money: initialMoney,
+ } = state
+ const oldLevel = levelAchieved(farmProductsSold(itemsSold))
+
+ let { loanBalance } = state
+ let saleValue = 0
+
+ const kegValue = getKegValue(keg)
+
+ const loanGarnishment = Math.min(
+ loanBalance,
+ castToMoney(kegValue * LOAN_GARNISHMENT_RATE)
+ )
+
+ const salePriceMultiplier = getSalePriceMultiplier(completedAchievements)
+
+ const garnishedProfit = kegValue * salePriceMultiplier - loanGarnishment
+
+ loanBalance = moneyTotal(loanBalance, -loanGarnishment)
+ saleValue = moneyTotal(saleValue, garnishedProfit)
+
+ state = adjustLoan(state, moneyTotal(loanBalance, -state.loanBalance))
+
+ // NOTE: This logic will need to be revisited to support Wine sales. Wines as
+ // items should be treated distinctly from their originating Grape items.
+ const newItemsSold = {
+ ...itemsSold,
+ [itemId]: (itemsSold[itemId] ?? 0) + 1,
+ }
+ const newCellarItemsSold = {
+ ...cellarItemsSold,
+ [itemId]: (cellarItemsSold[itemId] ?? 0) + 1,
+ }
+
+ state = {
+ ...state,
+ itemsSold: newItemsSold,
+ cellarItemsSold: newCellarItemsSold,
+ }
+
+ // money needs to be passed in explicitly here because state.money gets
+ // mutated above and addRevenue needs its initial value.
+ state = addRevenue({ ...state, money: initialMoney }, saleValue)
+
+ state = processLevelUp(state, oldLevel)
+ state = removeKegFromCellar(state, keg.id)
+
+ // NOTE: This notification will need to be revisited to support Wine sales.
+ state = prependPendingPeerMessage(
+ state,
+ SOLD_FERMENTED_ITEM_PEER_NOTIFICATION`${item}`,
+ 'warning'
+ )
+
+ state = updateLearnedRecipes(state)
+
+ return state
+}
diff --git a/src/game-logic/reducers/sellKeg.test.js b/src/game-logic/reducers/sellKeg.test.js
new file mode 100644
index 000000000..c1dec67b1
--- /dev/null
+++ b/src/game-logic/reducers/sellKeg.test.js
@@ -0,0 +1,276 @@
+/**
+ * @typedef {import("../../index").farmhand.keg} keg
+ */
+import { LOAN_GARNISHMENT_RATE } from '../../constants'
+import { carrot } from '../../data/crops'
+import { LOAN_PAYOFF } from '../../templates'
+import { castToMoney } from '../../utils'
+import { getKegValue } from '../../utils/getKegValue'
+
+import { sellKeg } from './sellKeg'
+
+/** @type keg */
+const stubKeg = { id: 'stub-keg', daysUntilMature: 4, itemId: carrot.id }
+
+const stubKegValue = getKegValue(stubKeg)
+
+describe('sellKeg', () => {
+ test('updates inventory', () => {
+ const state = sellKeg(
+ {
+ id: 'abc123',
+ cellarInventory: [stubKeg],
+ cellarItemsSold: {},
+ completedAchievements: {},
+ itemsSold: {},
+ learnedRecipes: {},
+ loanBalance: 500,
+ money: 1000,
+ pendingPeerMessages: [],
+ revenue: 0,
+ todaysRevenue: 0,
+ },
+ stubKeg
+ )
+
+ expect(state).toMatchObject({
+ cellarInventory: [],
+ })
+ })
+
+ test('updates sales records', () => {
+ const state = sellKeg(
+ {
+ id: 'abc123',
+ cellarInventory: [stubKeg],
+ cellarItemsSold: {},
+ completedAchievements: {},
+ itemsSold: {},
+ learnedRecipes: {},
+ loanBalance: 500,
+ money: 1000,
+ pendingPeerMessages: [],
+ revenue: 0,
+ todaysRevenue: 0,
+ },
+ stubKeg
+ )
+
+ expect(state).toMatchObject({
+ cellarItemsSold: { carrot: 1 },
+ itemsSold: { carrot: 1 },
+ pendingPeerMessages: [
+ {
+ id: 'abc123',
+ message: 'sold one unit of Fermented Carrot.',
+ severity: 'warning',
+ },
+ ],
+ })
+ })
+
+ test('applies achievement bonus', () => {
+ const state = sellKeg(
+ {
+ id: 'abc123',
+ cellarInventory: [stubKeg],
+ cellarItemsSold: {},
+ completedAchievements: { 'i-am-rich-1': true },
+ itemsSold: {},
+ learnedRecipes: {},
+ loanBalance: 500,
+ money: 1000,
+ pendingPeerMessages: [],
+ revenue: 0,
+ todaysRevenue: 0,
+ },
+ stubKeg
+ )
+
+ const adjustedKegValue = stubKegValue * 1.05
+
+ expect(state).toMatchObject({
+ loanBalance: castToMoney(500 - stubKegValue * LOAN_GARNISHMENT_RATE),
+ money: castToMoney(
+ 1000 + adjustedKegValue - stubKegValue * LOAN_GARNISHMENT_RATE
+ ),
+ revenue: castToMoney(
+ adjustedKegValue - stubKegValue * LOAN_GARNISHMENT_RATE
+ ),
+ todaysRevenue: castToMoney(
+ adjustedKegValue - stubKegValue * LOAN_GARNISHMENT_RATE
+ ),
+ })
+ })
+
+ test('updates learnedRecipes', () => {
+ const state = sellKeg(
+ {
+ id: 'abc123',
+ cellarInventory: [stubKeg],
+ cellarItemsSold: {},
+ completedAchievements: {},
+ itemsSold: { carrot: 9 },
+ learnedRecipes: {},
+ loanBalance: 500,
+ money: 1000,
+ pendingPeerMessages: [],
+ revenue: 0,
+ todaysRevenue: 0,
+ },
+ stubKeg
+ )
+
+ expect(state).toMatchObject({
+ learnedRecipes: { 'carrot-soup': true },
+ itemsSold: { carrot: 10 },
+ })
+ })
+
+ describe('there is an outstanding loan', () => {
+ describe('loan is greater than garnishment', () => {
+ test('sale is garnished', () => {
+ const state = sellKeg(
+ {
+ id: 'abc123',
+ cellarInventory: [stubKeg],
+ cellarItemsSold: {},
+ completedAchievements: {},
+ itemsSold: {},
+ learnedRecipes: {},
+ loanBalance: 500,
+ money: 1000,
+ pendingPeerMessages: [],
+ revenue: 0,
+ todaysRevenue: 0,
+ },
+ stubKeg
+ )
+
+ expect(state).toMatchObject({
+ loanBalance: castToMoney(500 - stubKegValue * LOAN_GARNISHMENT_RATE),
+ money: castToMoney(
+ 1000 + stubKegValue - stubKegValue * LOAN_GARNISHMENT_RATE
+ ),
+ revenue: castToMoney(
+ stubKegValue - stubKegValue * LOAN_GARNISHMENT_RATE
+ ),
+ todaysRevenue: castToMoney(
+ stubKegValue - stubKegValue * LOAN_GARNISHMENT_RATE
+ ),
+ })
+ })
+ })
+
+ describe('loan is less than garnishment', () => {
+ test('loan is payed off', () => {
+ const state = sellKeg(
+ {
+ id: 'abc123',
+ cellarInventory: [stubKeg],
+ cellarItemsSold: {},
+ completedAchievements: {},
+ itemsSold: {},
+ learnedRecipes: {},
+ loanBalance: 1,
+ money: 1000,
+ pendingPeerMessages: [],
+ revenue: 0,
+ todaysRevenue: 0,
+ todaysNotifications: [],
+ },
+ stubKeg
+ )
+
+ expect(state).toMatchObject({
+ loanBalance: 0,
+ })
+ })
+
+ test('sale is reduced by remaining loan balance', () => {
+ const state = sellKeg(
+ {
+ id: 'abc123',
+ cellarInventory: [stubKeg],
+ cellarItemsSold: {},
+ completedAchievements: {},
+ itemsSold: {},
+ learnedRecipes: {},
+ loanBalance: 1,
+ money: 1000,
+ pendingPeerMessages: [],
+ revenue: 0,
+ todaysRevenue: 0,
+ todaysNotifications: [],
+ },
+ stubKeg
+ )
+
+ expect(state).toMatchObject({
+ money: castToMoney(1000 + stubKegValue - 1),
+ revenue: castToMoney(stubKegValue - 1),
+ todaysRevenue: castToMoney(stubKegValue - 1),
+ })
+ })
+
+ test('payoff notification is shown', () => {
+ const state = sellKeg(
+ {
+ id: 'abc123',
+ cellarInventory: [stubKeg],
+ cellarItemsSold: {},
+ completedAchievements: {},
+ itemsSold: {},
+ learnedRecipes: {},
+ loanBalance: 1,
+ money: 1000,
+ pendingPeerMessages: [],
+ revenue: 0,
+ todaysRevenue: 0,
+ todaysNotifications: [],
+ },
+ stubKeg
+ )
+
+ expect(state).toMatchObject({
+ todaysNotifications: [
+ { message: LOAN_PAYOFF``, severity: 'success' },
+ ],
+ })
+ })
+ })
+ })
+
+ describe('there is not an outstanding loan', () => {
+ test('sale is not garnished', () => {
+ const state = sellKeg(
+ {
+ id: 'abc123',
+ cellarInventory: [stubKeg],
+ cellarItemsSold: {},
+ completedAchievements: {},
+ itemsSold: {},
+ learnedRecipes: {},
+ loanBalance: 0,
+ money: 1000,
+ pendingPeerMessages: [],
+ revenue: 0,
+ todaysRevenue: 0,
+ },
+ stubKeg
+ )
+
+ expect(state).toMatchObject({
+ cellarInventory: [],
+ cellarItemsSold: { carrot: 1 },
+ completedAchievements: {},
+ itemsSold: { carrot: 1 },
+ learnedRecipes: {},
+ loanBalance: 0,
+ money: castToMoney(1000 + stubKegValue),
+ revenue: castToMoney(stubKegValue),
+ todaysRevenue: castToMoney(stubKegValue),
+ })
+ })
+ })
+})
diff --git a/src/handlers/ui-events.js b/src/handlers/ui-events.js
index a7833cb3b..a21269717 100644
--- a/src/handlers/ui-events.js
+++ b/src/handlers/ui-events.js
@@ -1,3 +1,7 @@
+/**
+ * @typedef {import("../index").farmhand.item} item
+ * @typedef {import("../index").farmhand.keg} keg
+ */
import { saveAs } from 'file-saver'
import window from 'global/window'
@@ -35,9 +39,12 @@ const {
WATER,
} = fieldMode
+// All of the functions exported here are bound to the Farmhand component
+// class. See the definition of initInputHandlers:
+// https://github.com/search?q=repo%3Ajeremyckahn%2Ffarmhand+path%3A**%2FFarmhand.js+%2FeventHandlers.*bind%2F&type=code
export default {
/**
- * @param {farmhand.item} item
+ * @param {item} item
* @param {number} [howMany=1]
*/
handleItemPurchaseClick(item, howMany = 1) {
@@ -52,6 +59,28 @@ export default {
this.makeRecipe(recipe, howMany)
},
+ /**
+ * @param {item} fermentationRecipe
+ * @param {number} [howMany=1]
+ */
+ handleMakeFermentationRecipeClick(fermentationRecipe, howMany = 1) {
+ this.makeFermentationRecipe(fermentationRecipe, howMany)
+ },
+
+ /**
+ * @param {keg} keg
+ */
+ handleSellKegClick(keg) {
+ this.sellKeg(keg)
+ },
+
+ /**
+ * @param {keg} keg
+ */
+ handleThrowAwayKegClick(keg) {
+ this.removeKegFromCellar(keg.id)
+ },
+
/**
* @param {farmhand.upgrade} upgrade
*/
@@ -126,7 +155,7 @@ export default {
},
/**
- * @param {farmhand.item} item
+ * @param {item} item
* @param {number} [howMany=1]
*/
handleItemSellClick(item, howMany = 1) {
@@ -161,13 +190,9 @@ export default {
},
/**
- * @param {farmhand.item} item
+ * @param {item} item
*/
- handleItemSelectClick({
- id,
- enablesFieldMode,
- hoveredPlotRangeSize: newHoveredPlotRangeSize,
- }) {
+ handleItemSelectClick({ id, enablesFieldMode }) {
this.setState({
fieldMode: enablesFieldMode,
selectedItemId: id,
diff --git a/src/index.js b/src/index.js
index 678076521..0e02ad048 100644
--- a/src/index.js
+++ b/src/index.js
@@ -30,6 +30,8 @@
* @property {number} [quantity] How many of the item the player has.
* @property {number} [tier] The value tier that the item belongs to.
* @property {number?} [spawnChance] The respawn rate for the item.
+ * @property {number?} [daysToFerment] This number is defined if the item can
+ * be fermented.
*/
/**
@@ -117,6 +119,16 @@
* the recipe to be made available to the player.
*/
+/**
+ * @typedef farmhand.keg
+ * @type {Object}
+ * @property {string} id UUID to uniquely identify the keg.
+ * @property {string} itemId The item that this keg is based on.
+ * @property {number} daysUntilMature Days remaining until this recipe can be
+ * sold. This value can go negative to indicate "days since fermented." When
+ * negative, the value of the keg is increased.
+ */
+
/**
* @typedef farmhand.priceEvent
* @type {Object}
@@ -190,6 +202,13 @@
* @type {Object.}
*/
+/**
+ * @typedef {Object} farmhand.levelEntitlements
+ * @property {number} sprinklerRange
+ * @property {Object.} items
+ * @property {Object.} tools
+ */
+
import './polyfills'
import React from 'react'
import ReactDOM from 'react-dom'
diff --git a/src/templates.js b/src/templates.js
index 1c6b30426..8f5a07873 100644
--- a/src/templates.js
+++ b/src/templates.js
@@ -267,6 +267,14 @@ export const PURCHASED_ITEM_PEER_NOTIFICATION = (_, quantity, { name }) =>
export const SOLD_ITEM_PEER_NOTIFICATION = (_, quantity, { name }) =>
`sold ${integerString(quantity)} unit${quantity > 1 ? 's' : ''} of ${name}.`
+/**
+ * @param {string} _
+ * @param {farmhand.item} item
+ * @returns {string}
+ */
+export const SOLD_FERMENTED_ITEM_PEER_NOTIFICATION = (_, item) =>
+ `sold one unit of ${FERMENTED_CROP_NAME`${item}`}.`
+
/**
* @param {string} toolName - the name of the tool being replaced
* @param {string} upgradedName - the new name of the tool
@@ -320,3 +328,10 @@ export const COW_TRADED_NOTIFICATION = (
* @returns {string}
*/
export const SHOVELED_PLOT = (_, item) => `Shoveled plot of ${item.name}`
+
+/**
+ * @param {string} _
+ * @param {farmhand.item} item
+ * @returns {string}
+ */
+export const FERMENTED_CROP_NAME = (_, item) => `Fermented ${item.name}`
diff --git a/src/utils/doesCellarSpaceRemain.js b/src/utils/doesCellarSpaceRemain.js
new file mode 100644
index 000000000..0f4d33bb1
--- /dev/null
+++ b/src/utils/doesCellarSpaceRemain.js
@@ -0,0 +1,14 @@
+/** @typedef {import("../index").farmhand.keg} keg */
+
+import { PURCHASEABLE_CELLARS } from '../constants'
+
+/**
+ * @param {Array.} cellarInventory
+ * @param {number} purchasedCellar
+ * @returns {boolean}
+ */
+export const doesCellarSpaceRemain = (cellarInventory, purchasedCellar) => {
+ return (
+ cellarInventory.length < PURCHASEABLE_CELLARS.get(purchasedCellar).space
+ )
+}
diff --git a/src/utils/getCropLifecycleDuration.js b/src/utils/getCropLifecycleDuration.js
new file mode 100644
index 000000000..588f4bff7
--- /dev/null
+++ b/src/utils/getCropLifecycleDuration.js
@@ -0,0 +1,11 @@
+import { memoize } from './memoize'
+
+// TODO: Refactor this to accept just a plain cropTimetable
+// https://github.com/jeremyckahn/farmhand/issues/415
+/**
+ * @param {{ cropTimetable: Object }} crop
+ * @returns {number}
+ */
+export const getCropLifecycleDuration = memoize(({ cropTimetable }) =>
+ Object.values(cropTimetable).reduce((acc, value) => acc + value, 0)
+)
diff --git a/src/utils/getCropLifecycleDuration.test.js b/src/utils/getCropLifecycleDuration.test.js
new file mode 100644
index 000000000..c49047575
--- /dev/null
+++ b/src/utils/getCropLifecycleDuration.test.js
@@ -0,0 +1,11 @@
+import { sampleCropItem1 } from '../data/items'
+
+import { getCropLifecycleDuration } from './getCropLifecycleDuration'
+
+jest.mock('../data/items')
+
+describe('getCropLifecycleDuration', () => {
+ test('computes lifecycle duration', () => {
+ expect(getCropLifecycleDuration(sampleCropItem1)).toEqual(3)
+ })
+})
diff --git a/src/utils/getCropsAvailableToFerment.js b/src/utils/getCropsAvailableToFerment.js
new file mode 100644
index 000000000..86fd56a6a
--- /dev/null
+++ b/src/utils/getCropsAvailableToFerment.js
@@ -0,0 +1,19 @@
+/**
+ * @typedef {import('../index').farmhand.levelEntitlements} levelEntitlements
+ * @typedef {import('../index').farmhand.item} item
+ */
+import { itemsMap } from '../data/maps'
+
+import { getFinalCropItemFromSeedItem } from '.'
+
+/**
+ * @param {levelEntitlements} levelEntitlements
+ * @returns {item[]}
+ */
+export function getCropsAvailableToFerment(levelEntitlements) {
+ const cropsAvailableToFerment = Object.keys(levelEntitlements.items)
+ .map(itemId => getFinalCropItemFromSeedItem(itemsMap[itemId]))
+ .filter(item => (item ? 'daysToFerment' in item : false))
+
+ return cropsAvailableToFerment
+}
diff --git a/src/utils/getCropsAvailableToFerment.test.js b/src/utils/getCropsAvailableToFerment.test.js
new file mode 100644
index 000000000..b01046c23
--- /dev/null
+++ b/src/utils/getCropsAvailableToFerment.test.js
@@ -0,0 +1,20 @@
+import { carrot, pumpkin, spinach } from '../data/crops'
+
+import { getCropsAvailableToFerment } from './getCropsAvailableToFerment'
+import { getLevelEntitlements } from './getLevelEntitlements'
+
+describe('getCropsAvailableToFerment', () => {
+ test.each([
+ [0, []],
+ [5, [carrot, spinach, pumpkin]],
+ ])(
+ 'calculates crops that are available for fermentation',
+ (level, expectedCropsAvailableToFerment) => {
+ const cropsAvailableToFerment = getCropsAvailableToFerment(
+ getLevelEntitlements(level)
+ )
+
+ expect(cropsAvailableToFerment).toEqual(expectedCropsAvailableToFerment)
+ }
+ )
+})
diff --git a/src/utils/getInventoryQuantityMap.js b/src/utils/getInventoryQuantityMap.js
new file mode 100644
index 000000000..667bf1682
--- /dev/null
+++ b/src/utils/getInventoryQuantityMap.js
@@ -0,0 +1,20 @@
+/**
+ * @typedef {import("../index").farmhand.item} item
+ */
+import { memoize } from './memoize'
+
+/**
+ * @param {item[]} inventory
+ * @returns {Object}
+ */
+export const getInventoryQuantityMap = memoize(
+ /**
+ * @param {item[]} inventory
+ * @returns {Object}
+ */
+ inventory =>
+ inventory.reduce((acc, { id, quantity }) => {
+ acc[id] = quantity
+ return acc
+ }, {})
+)
diff --git a/src/utils/getItemBaseValue.js b/src/utils/getItemBaseValue.js
new file mode 100644
index 000000000..e6e7c9f7f
--- /dev/null
+++ b/src/utils/getItemBaseValue.js
@@ -0,0 +1,7 @@
+import { itemsMap } from '../data/maps'
+
+/**
+ * @param {string} itemId
+ * @returns {number}
+ */
+export const getItemBaseValue = itemId => itemsMap[itemId].value
diff --git a/src/utils/getKegValue.js b/src/utils/getKegValue.js
new file mode 100644
index 000000000..09b38c93e
--- /dev/null
+++ b/src/utils/getKegValue.js
@@ -0,0 +1,28 @@
+/** @typedef {import("../index").farmhand.keg} keg */
+
+import { KEG_INTEREST_RATE } from '../constants'
+import { itemsMap } from '../data/maps'
+
+import { getItemBaseValue } from './getItemBaseValue'
+
+/**
+ * @param {keg} keg
+ */
+export const getKegValue = keg => {
+ const { itemId } = keg
+ const kegItem = itemsMap[itemId]
+ const principalValue = (kegItem.tier ?? 1) * getItemBaseValue(itemId)
+
+ // Standard compound interest rate formula:
+ // A = P(1 + r/n)^nt
+ //
+ // A = final amount
+ // P = initial principal balance
+ // r = interest rate
+ // n = number of times interest applied per time period
+ // t = number of time periods elapsed
+ const kegValue =
+ principalValue * (1 + KEG_INTEREST_RATE) ** Math.abs(keg.daysUntilMature)
+
+ return kegValue
+}
diff --git a/src/utils/getLevelEntitlements.js b/src/utils/getLevelEntitlements.js
new file mode 100644
index 000000000..15a49daa8
--- /dev/null
+++ b/src/utils/getLevelEntitlements.js
@@ -0,0 +1,46 @@
+/** @typedef {import("../index").farmhand.levelEntitlements} levelEntitlements */
+import { levels } from '../data/levels'
+import { INITIAL_SPRINKLER_RANGE } from '../constants'
+
+import { memoize } from './memoize'
+
+/**
+ * @param {number} levelNumber
+ * @returns {levelEntitlements} Contains `sprinklerRange` and keys that correspond to
+ * unlocked items.
+ */
+export const getLevelEntitlements = memoize(
+ /**
+ * @param {number} levelNumber
+ * @returns {levelEntitlements}
+ */
+ levelNumber => {
+ /** @type levelEntitlements */
+ const acc = {
+ sprinklerRange: INITIAL_SPRINKLER_RANGE,
+ items: {},
+ tools: {},
+ }
+
+ // Assumes that levels is sorted by id.
+ levels.find(
+ ({ unlocksShopItem, unlocksTool, id, increasesSprinklerRange }) => {
+ if (increasesSprinklerRange) {
+ acc.sprinklerRange++
+ }
+
+ if (unlocksShopItem) {
+ acc.items[unlocksShopItem] = true
+ }
+
+ if (unlocksTool) {
+ acc.tools[unlocksTool] = true
+ }
+
+ return id === levelNumber
+ }
+ )
+
+ return acc
+ }
+)
diff --git a/src/utils/getLevelEntitlements.test.js b/src/utils/getLevelEntitlements.test.js
new file mode 100644
index 000000000..39021fbde
--- /dev/null
+++ b/src/utils/getLevelEntitlements.test.js
@@ -0,0 +1,21 @@
+import { getLevelEntitlements } from './getLevelEntitlements'
+
+describe('getLevelEntitlements', () => {
+ test('calculates level entitlements', () => {
+ const entitlements = getLevelEntitlements(8)
+
+ expect(entitlements).toEqual({
+ items: {
+ 'carrot-seed': true,
+ fertilizer: true,
+ 'pumpkin-seed': true,
+ 'spinach-seed': true,
+ sprinkler: true,
+ },
+ sprinklerRange: 2,
+ tools: {
+ SHOVEL: true,
+ },
+ })
+ })
+})
diff --git a/src/utils/getMaxYieldOfFermentationRecipe.js b/src/utils/getMaxYieldOfFermentationRecipe.js
new file mode 100644
index 000000000..0af88141b
--- /dev/null
+++ b/src/utils/getMaxYieldOfFermentationRecipe.js
@@ -0,0 +1,40 @@
+/** @typedef {import("../index").farmhand.item} item */
+/** @typedef {import("../index").farmhand.keg} keg */
+
+import { itemsMap } from '../data/maps'
+
+import { getInventoryQuantityMap } from './getInventoryQuantityMap'
+
+import { getSaltRequirementsForFermentationRecipe } from './getSaltRequirementsForFermentationRecipe'
+
+/**
+ * @param {item} fermentationRecipe
+ * @param {{ id: string, quantity: number }} inventory
+ * @param {Array.} cellarInventory
+ * @param {number} cellarSize
+ * @returns {number}
+ */
+export const getMaxYieldOfFermentationRecipe = (
+ fermentationRecipe,
+ inventory,
+ cellarInventory,
+ cellarSize
+) => {
+ const {
+ [fermentationRecipe.id]: itemQuantityInInventory = 0,
+ [itemsMap.salt.id]: saltQuantityInInventory = 0,
+ } = getInventoryQuantityMap(inventory)
+
+ const maxSaltYieldPotential = Math.floor(
+ saltQuantityInInventory /
+ getSaltRequirementsForFermentationRecipe(fermentationRecipe)
+ )
+
+ const maxYield = Math.min(
+ cellarSize - cellarInventory.length,
+ itemQuantityInInventory,
+ maxSaltYieldPotential
+ )
+
+ return maxYield
+}
diff --git a/src/utils/getMaxYieldOfFermentationRecipe.test.js b/src/utils/getMaxYieldOfFermentationRecipe.test.js
new file mode 100644
index 000000000..8dde826bb
--- /dev/null
+++ b/src/utils/getMaxYieldOfFermentationRecipe.test.js
@@ -0,0 +1,63 @@
+import { v4 as uuid } from 'uuid'
+
+import { carrot } from '../data/crops'
+import { salt } from '../data/recipes'
+
+import { getMaxYieldOfFermentationRecipe } from './getMaxYieldOfFermentationRecipe'
+
+describe('getMaxYieldOfFermentationRecipe', () => {
+ test.each([
+ // Happy path
+ [
+ [
+ { id: carrot.id, quantity: 10 },
+ { id: salt.id, quantity: 10 },
+ ],
+ [{ id: uuid(), itemId: carrot.id, daysUntilMature: 0 }],
+ 10,
+ 2,
+ ],
+ // Insufficient salt
+ [
+ [
+ { id: carrot.id, quantity: 10 },
+ { id: salt.id, quantity: 0 },
+ ],
+ [{ id: uuid(), itemId: carrot.id, daysUntilMature: 0 }],
+ 10,
+ 0,
+ ],
+ // Insufficient item quantity in inventory
+ [
+ [
+ { id: carrot.id, quantity: 0 },
+ { id: salt.id, quantity: 10 },
+ ],
+ [{ id: uuid(), itemId: carrot.id, daysUntilMature: 0 }],
+ 10,
+ 0,
+ ],
+ // Insufficient cellar space
+ [
+ [
+ { id: carrot.id, quantity: 10 },
+ { id: salt.id, quantity: 10 },
+ ],
+ [{ id: uuid(), itemId: carrot.id, daysUntilMature: 0 }],
+ 1,
+ 0,
+ ],
+ ])(
+ 'computes max potential yield of a given recipe',
+ (inventory, cellarInventory, cellarSize, expectedYield) => {
+ const maxYield = getMaxYieldOfFermentationRecipe(
+ carrot,
+ inventory,
+ cellarInventory,
+ cellarSize
+ )
+
+ expect(maxYield).toBe(expectedYield)
+ }
+ )
+})
diff --git a/src/utils/getSaltRequirementsForFermentationRecipe.js b/src/utils/getSaltRequirementsForFermentationRecipe.js
new file mode 100644
index 000000000..d50d34850
--- /dev/null
+++ b/src/utils/getSaltRequirementsForFermentationRecipe.js
@@ -0,0 +1,13 @@
+/** @typedef {import("../index").farmhand.item} farmhand.item */
+
+const saltRequirementMultiplier = 2 / 3
+
+/**
+ * @param {farmhand.item} fermentationRecipe
+ * @returns {number}
+ */
+export const getSaltRequirementsForFermentationRecipe = fermentationRecipe => {
+ const { daysToFerment = 0, tier = 1 } = fermentationRecipe
+
+ return Math.ceil(daysToFerment * saltRequirementMultiplier) * tier
+}
diff --git a/src/utils/index.js b/src/utils/index.js
index dc9efcfe2..cc2c358c1 100644
--- a/src/utils/index.js
+++ b/src/utils/index.js
@@ -10,7 +10,6 @@
import { Buffer } from 'buffer'
import Dinero from 'dinero.js'
-import fastMemoize from 'fast-memoize'
import configureJimp from '@jimp/custom'
import jimpPng from '@jimp/png'
import sortBy from 'lodash.sortby'
@@ -30,7 +29,6 @@ import {
rainbowMilk2,
rainbowMilk3,
} from '../data/items'
-import { levels } from '../data/levels'
import { unlockableItems } from '../data/levels'
import { items as itemImages, animals, pixel } from '../img'
import {
@@ -63,10 +61,8 @@ import {
INFINITE_STORAGE_LIMIT,
INITIAL_FIELD_HEIGHT,
INITIAL_FIELD_WIDTH,
- INITIAL_SPRINKLER_RANGE,
INITIAL_STORAGE_LIMIT,
MALE_COW_WEIGHT_MULTIPLIER,
- MEMOIZE_CACHE_CLEAR_THRESHOLD,
PEER_METADATA_STATE_KEYS,
PERSISTED_STATE_KEYS,
PRECIPITATION_CHANCE,
@@ -78,6 +74,12 @@ import {
} from '../constants'
import { random } from '../common/utils'
+import { memoize } from './memoize'
+import { getCropLifecycleDuration } from './getCropLifecycleDuration'
+import { getItemBaseValue } from './getItemBaseValue'
+import { getInventoryQuantityMap } from './getInventoryQuantityMap'
+import { getLevelEntitlements } from './getLevelEntitlements'
+
const Jimp = configureJimp({
types: [jimpPng],
})
@@ -115,49 +117,6 @@ const memoizationSerializer = args =>
[...args].map(arg => (typeof arg === 'function' ? arg.toString() : arg))
)
-// This is basically the same as fast-memoize's default cache, except that it
-// clears the cache once the size exceeds MEMOIZE_CACHE_CLEAR_THRESHOLD to
-// prevent memory bloat.
-// https://github.com/caiogondim/fast-memoize.js/blob/5cdfc8dde23d86b16e0104bae1b04cd447b98c63/src/index.js#L114-L128
-/**
- * @ignore
- */
-class MemoizeCache {
- cache = {}
-
- /**
- * @param {Object} [config] Can also contain the config options used to
- * configure fast-memoize.
- * @param {number} [config.cacheSize]
- * @see https://github.com/caiogondim/fast-memoize.js
- */
- constructor({ cacheSize = MEMOIZE_CACHE_CLEAR_THRESHOLD } = {}) {
- this.cacheSize = cacheSize
- }
-
- has(key) {
- return key in this.cache
- }
-
- get(key) {
- return this.cache[key]
- }
-
- set(key, value) {
- if (Object.keys(this.cache).length > this.cacheSize) {
- this.cache = {}
- }
-
- this.cache[key] = value
- }
-}
-
-export const memoize = (fn, config) =>
- fastMemoize(fn, {
- cache: { create: () => new MemoizeCache(config) },
- ...config,
- })
-
/**
* @param {number} num
* @param {number} min
@@ -231,12 +190,6 @@ export const integerString = number => formatNumber(number, '0,0')
*/
export const percentageString = number => `${Math.round(number * 100)}%`
-/**
- * @param {string} itemId
- * @returns {number}
- */
-const getItemBaseValue = itemId => itemsMap[itemId].value
-
/**
* @param {farmhand.item} item
* @param {Object.} valueAdjustments
@@ -326,14 +279,6 @@ export const isItemAFarmProduct = item =>
item.type === itemType.CRAFTED_ITEM
)
-/**
- * @param {farmhand.crop} crop
- * @returns {number}
- */
-export const getCropLifecycleDuration = memoize(({ cropTimetable }) =>
- Object.values(cropTimetable).reduce((acc, value) => acc + value, 0)
-)
-
/**
* @param {farmhand.cropTimetable} cropTimetable
* @returns {Array.}
@@ -656,17 +601,6 @@ export const getCowValue = (cow, computeSaleValue = false) =>
export const getCowSellValue = cow => getCowValue(cow, true)
-/**
- * @param {Array.} inventory
- * @returns {Object}
- */
-export const getInventoryQuantityMap = memoize(inventory =>
- inventory.reduce((acc, { id, quantity }) => {
- acc[id] = quantity
- return acc
- }, {})
-)
-
/**
* @param {farmhand.recipe} recipe
* @param {Array.} inventory
@@ -863,40 +797,6 @@ export const levelAchieved = farmProductsSold =>
export const farmProductSalesVolumeNeededForLevel = targetLevel =>
((targetLevel - 1) * 10) ** 2
-/**
- * @param {number} levelNumber
- * @returns {Object} Contains `sprinklerRange` and keys that correspond to
- * unlocked items.
- */
-export const getLevelEntitlements = memoize(levelNumber => {
- const acc = {
- sprinklerRange: INITIAL_SPRINKLER_RANGE,
- items: {},
- tools: {},
- }
-
- // Assumes that levels is sorted by id.
- levels.find(
- ({ unlocksShopItem, unlocksTool, id, increasesSprinklerRange }) => {
- if (increasesSprinklerRange) {
- acc.sprinklerRange++
- }
-
- if (unlocksShopItem) {
- acc.items[unlocksShopItem] = true
- }
-
- if (unlocksTool) {
- acc.tools[unlocksTool] = true
- }
-
- return id === levelNumber
- }
- )
-
- return acc
-})
-
/**
* @param {Object} levelEntitlements
* @returns {Array.<{ item: farmhand.item }>}
diff --git a/src/utils/index.test.js b/src/utils/index.test.js
index 747bbfe6a..6a75a6d2b 100644
--- a/src/utils/index.test.js
+++ b/src/utils/index.test.js
@@ -42,7 +42,6 @@ import {
getCowValue,
getCowWeight,
getCropLifeStage,
- getCropLifecycleDuration,
getFinalCropItemIdFromSeedItemId,
getSeedItemIdFromFinalStageCropItemId,
getItemCurrentValue,
@@ -534,12 +533,6 @@ describe('getLifeStageRange', () => {
})
})
-describe('getCropLifecycleDuration', () => {
- test('computes lifecycle duration', () => {
- expect(getCropLifecycleDuration(sampleCropItem1)).toEqual(3)
- })
-})
-
describe('getCropLifeStage', () => {
test('maps a life cycle label to an image name chunk', () => {
const itemId = 'sample-crop-1'
@@ -793,41 +786,6 @@ describe('farmProductSalesVolumeNeededForLevel', () => {
})
})
-describe('getLevelEntitlements', () => {
- let entitlements = null
-
- beforeEach(() => {
- jest.resetModules()
- jest.mock('../data/levels', () => ({
- levels: [
- { id: 0 },
- { id: 1 },
- { id: 2, unlocksShopItem: 'sample-item-1' },
- { id: 3, increasesSprinklerRange: true },
- { id: 4, unlocksShopItem: 'sample-item-2' },
- { id: 5, unlocksTool: 'shovel' },
- { id: 6, increasesSprinklerRange: true },
- { id: 7, unlocksShopItem: 'sample-item-3' },
- ],
- }))
-
- entitlements = jest.requireActual('./index').getLevelEntitlements(5)
- })
-
- test('calculates level entitlements', () => {
- expect(entitlements).toEqual({
- items: {
- 'sample-item-1': true,
- 'sample-item-2': true,
- },
- sprinklerRange: 2,
- tools: {
- shovel: true,
- },
- })
- })
-})
-
describe('getAvailableShopInventory', () => {
test('computes shop inventory that has been unlocked', () => {
jest.resetModules()
diff --git a/src/utils/memoize.js b/src/utils/memoize.js
new file mode 100644
index 000000000..8ae7f73d1
--- /dev/null
+++ b/src/utils/memoize.js
@@ -0,0 +1,52 @@
+import fastMemoize from 'fast-memoize'
+
+import { MEMOIZE_CACHE_CLEAR_THRESHOLD } from '../constants'
+
+// This is basically the same as fast-memoize's default cache, except that it
+// clears the cache once the size exceeds MEMOIZE_CACHE_CLEAR_THRESHOLD to
+// prevent memory bloat.
+// https://github.com/caiogondim/fast-memoize.js/blob/5cdfc8dde23d86b16e0104bae1b04cd447b98c63/src/index.js#L114-L128
+/**
+ * @ignore
+ */
+export class MemoizeCache {
+ cache = {}
+
+ /**
+ * @param {Object} [config] Can also contain the config options used to
+ * configure fast-memoize.
+ * @param {number} [config.cacheSize]
+ * @see https://github.com/caiogondim/fast-memoize.js
+ */
+ constructor({ cacheSize = MEMOIZE_CACHE_CLEAR_THRESHOLD } = {}) {
+ this.cacheSize = cacheSize
+ }
+
+ has(key) {
+ return key in this.cache
+ }
+
+ get(key) {
+ return this.cache[key]
+ }
+
+ set(key, value) {
+ if (Object.keys(this.cache).length > this.cacheSize) {
+ this.cache = {}
+ }
+
+ this.cache[key] = value
+ }
+}
+
+/**
+ * @param {function} fn
+ * @param {Object} [config]
+ * @param {number} [config.cacheSize]
+ * @see https://github.com/caiogondim/fast-memoize.js
+ */
+export const memoize = (fn, config) =>
+ fastMemoize(fn, {
+ cache: { create: () => new MemoizeCache(config) },
+ ...config,
+ })