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)} +

+ + +
    +
  • + + + + + +
  • +
+
+ ) +} + +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, + })