diff --git a/package-lock.json b/package-lock.json
index 406a15aa6..f8c01dcd5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@jeremyckahn/farmhand",
- "version": "1.18.12",
+ "version": "1.18.13",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@jeremyckahn/farmhand",
- "version": "1.18.12",
+ "version": "1.18.13",
"license": "GPL-2.0-or-later",
"dependencies": {
"@emotion/react": "^11.11.1",
@@ -13295,8 +13295,9 @@
"license": "MIT"
},
"node_modules/ejs": {
- "version": "3.1.8",
- "license": "Apache-2.0",
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"dependencies": {
"jake": "^10.8.5"
},
@@ -41977,7 +41978,9 @@
"dev": true
},
"ejs": {
- "version": "3.1.8",
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"requires": {
"jake": "^10.8.5"
}
diff --git a/package.json b/package.json
index caa4f46b4..4277b86c6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@jeremyckahn/farmhand",
- "version": "1.18.12",
+ "version": "1.18.13",
"publishConfig": {
"access": "public"
},
diff --git a/src/components/Cellar/Cellar.js b/src/components/Cellar/Cellar.js
index 82bcd1a38..2ba5f963c 100644
--- a/src/components/Cellar/Cellar.js
+++ b/src/components/Cellar/Cellar.js
@@ -4,6 +4,7 @@ import Tabs from '@mui/material/Tabs'
import { CellarInventoryTabPanel } from './CellarInventoryTabPanel'
import { FermentationTabPanel } from './FermentationTabPanel'
+import { WinemakingTabPanel } from './WinemakingTabPanel'
import { a11yProps } from './TabPanel'
import './Cellar.sass'
@@ -20,9 +21,11 @@ export const Cellar = () => {
>
+
+
)
}
diff --git a/src/components/Cellar/CellarInventoryTabPanel.js b/src/components/Cellar/CellarInventoryTabPanel.js
index 5c1a1735b..df4895331 100644
--- a/src/components/Cellar/CellarInventoryTabPanel.js
+++ b/src/components/Cellar/CellarInventoryTabPanel.js
@@ -7,7 +7,12 @@ import CardContent from '@mui/material/CardContent'
import ReactMarkdown from 'react-markdown'
import FarmhandContext from '../Farmhand/Farmhand.context'
-import { KEG_INTEREST_RATE, PURCHASEABLE_CELLARS } from '../../constants'
+import {
+ KEG_INTEREST_RATE,
+ PURCHASEABLE_CELLARS,
+ WINE_GROWTH_TIMELINE_CAP,
+ WINE_INTEREST_RATE,
+} from '../../constants'
import { integerString } from '../../utils'
@@ -54,7 +59,15 @@ export const CellarInventoryTabPanel = ({ index, currentTab }) => {
{...{
linkTarget: '_blank',
className: 'markdown',
- source: `This is your inventory of cellar kegs. Keg contents take time to reach maturity before they can be sold. Once they reach maturity, keg contents become higher in quality and their value compounds at a rate of ${KEG_INTEREST_RATE}% a day.`,
+ source: `This is your inventory of Cellar kegs.
+
+Keg contents take time to reach maturity before they can be sold. After they reach maturity, keg contents become higher in quality over time and their value grows.
+
+Kegs that contain fermented crops compound in value at a rate of ${KEG_INTEREST_RATE}% a day but have an increasing chance of spoiling.
+
+Kegs that contain wine compound in value at a rate of ${WINE_INTEREST_RATE}% for up to ${integerString(
+ WINE_GROWTH_TIMELINE_CAP
+ )} days and never spoil.`,
}}
/>
diff --git a/src/components/Cellar/FermentationTabPanel.js b/src/components/Cellar/FermentationTabPanel.js
index 10de93122..9a960e5f9 100644
--- a/src/components/Cellar/FermentationTabPanel.js
+++ b/src/components/Cellar/FermentationTabPanel.js
@@ -22,7 +22,7 @@ export const FermentationTabPanel = ({ index, currentTab }) => (
linkTarget: '_blank',
className: 'markdown',
source:
- 'Some items can be fermented. Fermented items become much more valuable over time!',
+ 'Some items can be fermented and become much more valuable over time.',
}}
/>
diff --git a/src/components/Cellar/Keg.js b/src/components/Cellar/Keg.js
index 99d6017f2..4b0b88ce2 100644
--- a/src/components/Cellar/Keg.js
+++ b/src/components/Cellar/Keg.js
@@ -7,7 +7,7 @@ import CardActions from '@mui/material/CardActions'
import Button from '@mui/material/Button'
import { itemsMap } from '../../data/maps'
-import { items } from '../../img'
+import { items, wines } from '../../img'
import FarmhandContext from '../Farmhand/Farmhand.context'
import { getKegValue } from '../../utils/getKegValue'
@@ -18,6 +18,8 @@ import AnimatedNumber from '../AnimatedNumber'
import './Keg.sass'
import { getKegSpoilageRate } from '../../utils/getKegSpoilageRate'
+import { wineService } from '../../services/wine'
+import { cellarService } from '../../services/cellar'
/**
* @param {Object} props
@@ -41,7 +43,11 @@ export function Keg({ keg }) {
} = useContext(FarmhandContext)
const item = itemsMap[keg.itemId]
- const fermentationRecipeName = FERMENTED_CROP_NAME`${item}`
+
+ let imageSrc = items[item.id]
+
+ // @ts-expect-error
+ let recipeName = FERMENTED_CROP_NAME`${item}`
const handleSellClick = () => {
handleSellKegClick(keg)
@@ -55,19 +61,24 @@ export function Keg({ keg }) {
const kegValue =
getKegValue(keg) * getSalePriceMultiplier(completedAchievements)
+ if (wineService.isWineRecipe(item)) {
+ imageSrc = wines[item.variety]
+ recipeName = item.name
+ }
+
const spoilageRate = getKegSpoilageRate(keg)
const spoilageRateDisplayValue = Number((spoilageRate * 100).toPrecision(2))
return (
}
subheader={
@@ -81,7 +92,9 @@ export function Keg({ keg }) {
{...{ number: kegValue, formatter: moneyString }}
/>
- Potential for spoilage: {spoilageRateDisplayValue}%
+ {cellarService.doesKegSpoil(keg) && (
+ Potential for spoilage: {spoilageRateDisplayValue}%
+ )}
>
) : (
Days until ready: {keg.daysUntilMature}
diff --git a/src/components/Cellar/WinemakingTabPanel.js b/src/components/Cellar/WinemakingTabPanel.js
new file mode 100644
index 000000000..75172e974
--- /dev/null
+++ b/src/components/Cellar/WinemakingTabPanel.js
@@ -0,0 +1,38 @@
+import React from 'react'
+import { number } from 'prop-types'
+import Divider from '@mui/material/Divider'
+import Card from '@mui/material/Card'
+import CardContent from '@mui/material/CardContent'
+import ReactMarkdown from 'react-markdown'
+
+import { WineRecipeList } from '../WineRecipeList/WineRecipeList'
+
+import { TabPanel } from './TabPanel'
+
+export const WinemakingTabPanel = ({ index, currentTab }) => (
+
+
+
+
+
+)
+
+WinemakingTabPanel.propTypes = {
+ currentTab: number.isRequired,
+ index: number.isRequired,
+}
diff --git a/src/components/Farmhand/Farmhand.context.js b/src/components/Farmhand/Farmhand.context.js
index 078c273c5..87986b748 100644
--- a/src/components/Farmhand/Farmhand.context.js
+++ b/src/components/Farmhand/Farmhand.context.js
@@ -1,5 +1,35 @@
+/**
+ * @typedef {import('../../').farmhand.item} farmhand.item
+ * @typedef {import('../../').farmhand.levelEntitlements} farmhand.levelEntitlements
+ * @typedef {import('./Farmhand').farmhand.state} farmhand.state
+ */
import { createContext } from 'react'
-const FarmhandContext = createContext()
+// eslint-disable-next-line no-unused-vars
+import uiEventHandlers from '../../handlers/ui-events'
+
+/**
+ * @type {import('react').Context<{
+ * gameState: farmhand.state & {
+ * blockInput: boolean,
+ * features: Record,
+ * fieldToolInventory: farmhand.item[],
+ * isChatAvailable: boolean,
+ * levelEntitlements: farmhand.levelEntitlements,
+ * plantableCropInventory: farmhand.item[],
+ * playerInventory: farmhand.item[],
+ * playerInventoryQuantities: Record,
+ * shopInventory: farmhand.item[],
+ * viewList: string[],
+ * viewTitle: string,
+ * }
+ * handlers: uiEventHandlers & { debounced: uiEventHandlers }
+ * }>}
+ */
+// @ts-expect-error
+const FarmhandContext = createContext({
+ gameState: {},
+ handlers: {},
+})
export default FarmhandContext
diff --git a/src/components/Farmhand/Farmhand.js b/src/components/Farmhand/Farmhand.js
index aa7e5a6b7..6e5f340b0 100644
--- a/src/components/Farmhand/Farmhand.js
+++ b/src/components/Farmhand/Farmhand.js
@@ -131,11 +131,14 @@ const { CLEANUP, HARVEST, MINE, OBSERVE, WATER, PLANT } = fieldMode
const emptyObject = Object.freeze({})
/*!
- * @param {Array.<{ id: farmhand.item, quantity: number }>} inventory
- * @param {Object.} valueAdjustments
- * @returns {Array.}
+ * @param {{ id: farmhand.item['id'], quantity: number }} inventory
+ * @param {Record} valueAdjustments
+ * @returns {farmhand.item[]}
*/
-export const computePlayerInventory = memoize((inventory, valueAdjustments) =>
+export const computePlayerInventory = memoize((
+ /** @type {{ id: farmhand.item['id'], quantity: number }[]} */ inventory,
+ /** @type {Record} */ valueAdjustments
+) =>
inventory.map(({ quantity, id }) => ({
quantity,
...itemsMap[id],
@@ -144,10 +147,12 @@ export const computePlayerInventory = memoize((inventory, valueAdjustments) =>
)
/*!
- * @param {Array.<{ id: farmhand.item }>} inventory
- * @returns {Array.<{ id: farmhand.item }>}
+ * @param {farmhand.item[]} inventory
+ * @returns {{ id: farmhand.item }[]}
*/
-export const getFieldToolInventory = memoize(inventory =>
+export const getFieldToolInventory = memoize((
+ /** @type {Array.} */ inventory
+) =>
inventory
.filter(({ id }) => {
const { enablesFieldMode } = itemsMap[id]
@@ -158,10 +163,12 @@ export const getFieldToolInventory = memoize(inventory =>
)
/*!
- * @param {Array.<{ id: farmhand.item }>} inventory
+ * @param {farmhand.item[]} inventory
* @returns {Array.<{ id: farmhand.item }>}
*/
-export const getPlantableCropInventory = memoize(inventory =>
+export const getPlantableCropInventory = memoize((
+ /** @type {farmhand.item[]} */ inventory
+) =>
inventory
.filter(({ id }) => itemsMap[id].isPlantableCrop)
.map(({ id }) => itemsMap[id])
@@ -235,9 +242,10 @@ const applyPriceEvents = (valueAdjustments, priceCrashes, priceSurges) => {
* @property {boolean} isAwaitingNetworkRequest
* @property {boolean} isCombineEnabled
* @property {boolean} isMenuOpen
- * @property {Object} itemsSold Keys are items IDs, values are the number of
- * 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 {Record} itemsSold Keys are items
+ * IDs, values are the number of 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
diff --git a/src/components/Farmhand/Farmhand.sass b/src/components/Farmhand/Farmhand.sass
index 6820334b1..5b97376be 100644
--- a/src/components/Farmhand/Farmhand.sass
+++ b/src/components/Farmhand/Farmhand.sass
@@ -9,6 +9,9 @@ body
@mixin markdown-styles
.markdown
+ p
+ margin: 1em 0
+
ul li
list-style: disc
margin-left: 1em
diff --git a/src/components/Farmhand/FarmhandReducers.js b/src/components/Farmhand/FarmhandReducers.js
index de7346713..d4b5948a0 100644
--- a/src/components/Farmhand/FarmhandReducers.js
+++ b/src/components/Farmhand/FarmhandReducers.js
@@ -78,6 +78,10 @@ export class FarmhandReducers extends Component {
throw new Error('Unimplemented')
}
/** @type BoundReducer */
+ makeWine() {
+ throw new Error('Unimplemented')
+ }
+ /** @type BoundReducer */
modifyCow() {
throw new Error('Unimplemented')
}
diff --git a/src/components/Farmhand/helpers/getInventoryQuantities.js b/src/components/Farmhand/helpers/getInventoryQuantities.js
index a197589ea..fe99cd995 100644
--- a/src/components/Farmhand/helpers/getInventoryQuantities.js
+++ b/src/components/Farmhand/helpers/getInventoryQuantities.js
@@ -1,12 +1,15 @@
+/**
+ * @typedef {import('../../../').farmhand.item} farmhand.item
+ */
import { itemsMap } from '../../../data/maps'
const itemIds = Object.keys(itemsMap)
/**
- * @param {Array.<{ id: farmhand.item, quantity: number }>} inventory
- * @returns {Object.}
+ * @param {Array.<{ id: farmhand.item['id'], quantity: number }>} inventory
*/
export const getInventoryQuantities = inventory => {
+ /** @type {Record} */
const quantities = {}
for (const itemId of itemIds) {
diff --git a/src/components/FermentationRecipeList/FermentationRecipe.js b/src/components/FermentationRecipeList/FermentationRecipe.js
index 0d7cc319b..c97661bd6 100644
--- a/src/components/FermentationRecipeList/FermentationRecipe.js
+++ b/src/components/FermentationRecipeList/FermentationRecipe.js
@@ -12,52 +12,24 @@ import Button from '@mui/material/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 { itemsMap } from '../../data/maps'
+import { cellarService } from '../../services/cellar'
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 }
-)
+import './FermentationRecipe.sass'
/**
* @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 },
@@ -66,8 +38,11 @@ export const FermentationRecipe = ({ item }) => {
const [quantity, setQuantity] = useState(1)
const inventoryQuantityMap = getInventoryQuantityMap(inventory)
+ // @ts-expect-error
const fermentationRecipeName = FERMENTED_CROP_NAME`${item}`
- const { space: cellarSize } = PURCHASEABLE_CELLARS.get(purchasedCellar)
+ const { space: cellarSize } = PURCHASEABLE_CELLARS.get(purchasedCellar) ?? {
+ space: 0,
+ }
useEffect(() => {
setQuantity(
@@ -84,7 +59,8 @@ export const FermentationRecipe = ({ item }) => {
}, [cellarInventory, cellarSize, inventory, item, quantity])
const canBeMade =
- quantity > 0 && doesCellarSpaceRemain(cellarInventory, purchasedCellar)
+ quantity > 0 &&
+ cellarService.doesCellarSpaceRemain(cellarInventory, purchasedCellar)
const handleMakeFermentationRecipe = () => {
if (canBeMade) {
@@ -99,7 +75,7 @@ export const FermentationRecipe = ({ item }) => {
cellarSize
)
- const recipeInstancesInCellar = getRecipesInstancesInCellar(
+ const recipeInstancesInCellar = cellarService.getItemInstancesInCellar(
cellarInventory,
item
)
diff --git a/src/components/QuantityInput/QuantityInput.js b/src/components/QuantityInput/QuantityInput.js
index ffd979a1c..d68ddb1b0 100644
--- a/src/components/QuantityInput/QuantityInput.js
+++ b/src/components/QuantityInput/QuantityInput.js
@@ -12,6 +12,8 @@ import { Span } from '../Elements'
import './QuantityInput.sass'
+export const QUANTITY_INPUT_PLACEHOLDER_TEXT = '0'
+
const QuantityNumberFormat = forwardRef(
({ min, max, onChange, ...rest }, ref) => (
{
- // clear the input when input is first selected so the user doesn't have to fight with clearing out default values
- handleUpdateNumber(undefined)
+ onFocus: event => {
+ // NOTE: This highlights the contents of the input so the user
+ // doesn't have to fight with clearing out the default value.
+ event.target.select()
},
// Bind to keyup to prevent spamming the event handler.
onKeyUp: ({ which }) => {
diff --git a/src/components/StatsView/StatsView.js b/src/components/StatsView/StatsView.js
index dad065e8f..f701a49fa 100644
--- a/src/components/StatsView/StatsView.js
+++ b/src/components/StatsView/StatsView.js
@@ -1,3 +1,6 @@
+/**
+ * @typedef {import('../Farmhand/Farmhand').farmhand.state} farmhand.state
+ */
import React from 'react'
import { array, object, number, string } from 'prop-types'
import classNames from 'classnames'
@@ -35,25 +38,32 @@ const ElevatedPaper = props => (
{props.children}
)
-const StatsView = ({
- cowsTraded,
- experience,
- farmName,
- historicalDailyLosses,
- historicalDailyRevenue,
- itemsSold,
- loansTakenOut,
- profitabilityStreak,
- record7dayProfitAverage,
- recordProfitabilityStreak,
- recordSingleDayProfit,
- revenue,
- todaysLosses,
- todaysRevenue,
+const StatsView = (
+ /**
+ * @type {farmhand.state &
+ * {totalFarmProductsSold?: number, currentLevel?: number}
+ * }
+ */
+ {
+ cowsTraded,
+ experience,
+ farmName,
+ historicalDailyLosses,
+ historicalDailyRevenue,
+ itemsSold,
+ loansTakenOut,
+ profitabilityStreak,
+ record7dayProfitAverage,
+ recordProfitabilityStreak,
+ recordSingleDayProfit,
+ revenue,
+ todaysLosses,
+ todaysRevenue,
- totalFarmProductsSold = farmProductsSold(itemsSold),
- currentLevel = levelAchieved(experience),
-}) => (
+ totalFarmProductsSold = farmProductsSold(itemsSold),
+ currentLevel = levelAchieved(experience),
+ }
+) => (
@@ -230,18 +240,17 @@ const StatsView = ({
- {sortBy(Object.entries(itemsSold), ([itemId]) => itemId).map(
- ([itemId, quantity]) => (
-
-
- {itemsMap[itemId].name}
-
-
- {integerString(quantity)}
-
-
- )
- )}
+ {sortBy(
+ Object.entries(itemsSold),
+ ([itemId]) => itemsMap[itemId].name
+ ).map(([itemId, quantity]) => (
+
+
+ {itemsMap[itemId].name}
+
+ {integerString(quantity)}
+
+ ))}
diff --git a/src/components/WineRecipeList/WineRecipe.js b/src/components/WineRecipeList/WineRecipe.js
new file mode 100644
index 000000000..419408ecb
--- /dev/null
+++ b/src/components/WineRecipeList/WineRecipe.js
@@ -0,0 +1,139 @@
+import React, { useContext, useEffect, useState } from 'react'
+import { oneOf } from 'prop-types'
+import Card from '@mui/material/Card'
+import CardHeader from '@mui/material/CardHeader'
+import CardActions from '@mui/material/CardActions'
+import Button from '@mui/material/Button'
+
+import {
+ grapeVarietyNameMap,
+ grapeVarietyToGrapeItemMap,
+} from '../../data/crops/grape'
+import { itemsMap } from '../../data/maps'
+import { wineService } from '../../services/wine'
+import { grapeVariety } from '../../enums'
+import { wines } from '../../img'
+import { integerString } from '../../utils'
+import { getInventoryQuantityMap } from '../../utils/getInventoryQuantityMap'
+import { getYeastRequiredForWine } from '../../utils/getYeastRequiredForWine'
+import FarmhandContext from '../Farmhand/Farmhand.context'
+import { GRAPES_REQUIRED_FOR_WINE, PURCHASEABLE_CELLARS } from '../../constants'
+import { cellarService } from '../../services/cellar'
+import QuantityInput from '../QuantityInput'
+import { yeast } from '../../data/recipes'
+
+/**
+ * @typedef {{
+ * wineVariety: grapeVariety
+ * }} WineRecipeProps
+ */
+
+/**
+ * @param {WineRecipeProps} props
+ */
+export const WineRecipe = ({ wineVariety }) => {
+ const {
+ gameState: { cellarInventory, inventory, purchasedCellar },
+ handlers: { handleMakeWineClick },
+ } = useContext(FarmhandContext)
+
+ const [quantity, setQuantity] = useState(1)
+ const wineName = grapeVarietyNameMap[wineVariety]
+ const grape = grapeVarietyToGrapeItemMap[wineVariety]
+ const wine = itemsMap[grape.wineId]
+ const { space: cellarSize } = PURCHASEABLE_CELLARS.get(purchasedCellar) ?? {
+ space: 0,
+ }
+
+ const inventoryQuantityMap = getInventoryQuantityMap(inventory)
+ const quantityOfGrape = inventoryQuantityMap[grape.id] ?? 0
+ const quantityOfYeast = inventoryQuantityMap[yeast.id] ?? 0
+
+ useEffect(() => {
+ setQuantity(
+ Math.min(
+ wineService.getMaxWineYield({
+ grape,
+ inventory,
+ cellarInventory,
+ cellarSize,
+ }),
+ Math.max(1, quantity)
+ )
+ )
+ }, [cellarInventory, cellarSize, grape, inventory, quantity, wineVariety])
+
+ const canBeMade =
+ quantity > 0 &&
+ quantityOfGrape >= GRAPES_REQUIRED_FOR_WINE &&
+ cellarService.doesCellarSpaceRemain(cellarInventory, purchasedCellar)
+
+ const disableMakeButton = !canBeMade || !quantity
+
+ const wineInstancesInCellar = cellarService.getItemInstancesInCellar(
+ cellarInventory,
+ wine
+ )
+
+ const maxQuantity = wineService.getMaxWineYield({
+ grape,
+ inventory,
+ cellarInventory,
+ cellarSize,
+ })
+
+ const handleMakeWine = () => {
+ if (canBeMade) {
+ handleMakeWineClick(grape, quantity)
+ }
+ }
+
+ return (
+
+ }
+ subheader={
+ <>
+
+ Days to mature:{' '}
+ {integerString(wineService.getDaysToMature(wineVariety))}
+
+
+ Units of {grape.name} required:{' '}
+ {integerString(GRAPES_REQUIRED_FOR_WINE)} (available:{' '}
+ {integerString(quantityOfGrape)})
+
+
+ Units of {yeast.name} required:{' '}
+ {integerString(getYeastRequiredForWine(wineVariety) * quantity)}{' '}
+ (available: {integerString(quantityOfYeast)})
+
+ In cellar: {integerString(wineInstancesInCellar ?? 0)}
+ >
+ }
+ >
+
+
+
+
+
+ )
+}
+
+WineRecipe.propTypes = {
+ wineVariety: oneOf(Object.keys(grapeVariety)),
+}
diff --git a/src/components/WineRecipeList/WineRecipe.test.js b/src/components/WineRecipeList/WineRecipe.test.js
new file mode 100644
index 000000000..ef6589f91
--- /dev/null
+++ b/src/components/WineRecipeList/WineRecipe.test.js
@@ -0,0 +1,294 @@
+/**
+ * @typedef {import('../Farmhand/Farmhand').farmhand.state} farmhand.state
+ * @typedef {import('./WineRecipe').WineRecipeProps} WineRecipeProps
+ */
+
+import React from 'react'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+
+// eslint-disable-next-line no-unused-vars
+import uiHandlers from '../../handlers/ui-events'
+import FarmhandContext from '../Farmhand/Farmhand.context'
+import { wineService } from '../../services/wine'
+
+import {
+ grapeChardonnay,
+ grapeNebbiolo,
+ grapeSauvignonBlanc,
+} from '../../data/crops'
+import { yeast } from '../../data/recipes'
+import { GRAPES_REQUIRED_FOR_WINE } from '../../constants'
+import { integerString } from '../../utils'
+import { getYeastRequiredForWine } from '../../utils/getYeastRequiredForWine'
+
+import { getKegStub } from '../../test-utils/stubs/getKegStub'
+
+import { QUANTITY_INPUT_PLACEHOLDER_TEXT } from '../QuantityInput/QuantityInput'
+
+import { WineRecipe } from './WineRecipe'
+
+/** @type {Pick} */
+const stubGameState = {
+ cellarInventory: [],
+ inventory: [{ id: grapeChardonnay.id, quantity: 1 }],
+ purchasedCellar: 1,
+}
+
+/** @type {Pick} */
+const stubHandlers = {
+ handleMakeWineClick: jest.fn(),
+}
+
+/**
+ * @param {Partial<{
+ * props: Partial,
+ * state: Partial,
+ * handlers: Partial
+ * }>} props
+ */
+const WineRecipeStub = (
+ { props, state, handlers } = {
+ props: { wineVariety: grapeChardonnay.variety },
+ state: stubGameState,
+ handlers: stubHandlers,
+ }
+) => {
+ return (
+
+
+
+ )
+}
+
+describe('WineRecipe', () => {
+ test.each([
+ {
+ grape: grapeChardonnay,
+ daysToMature: wineService.getDaysToMature(grapeChardonnay.variety),
+ },
+ {
+ grape: grapeSauvignonBlanc,
+ daysToMature: wineService.getDaysToMature(grapeSauvignonBlanc.variety),
+ },
+ {
+ grape: grapeNebbiolo,
+ daysToMature: wineService.getDaysToMature(grapeNebbiolo.variety),
+ },
+ ])(
+ 'shows $daysToMature days to mature for $grape.id',
+ ({ grape, daysToMature }) => {
+ render(
+
+ )
+
+ const label = screen.getByText(`Days to mature: ${daysToMature}`)
+
+ expect(label).toBeInTheDocument()
+ }
+ )
+
+ test.each([
+ {
+ grape: grapeChardonnay,
+ quantity: 1,
+ },
+ {
+ grape: grapeSauvignonBlanc,
+ quantity: 10,
+ },
+ {
+ grape: grapeNebbiolo,
+ quantity: 100000,
+ },
+ ])('shows grape requirements for $grape.wineId', ({ grape, quantity }) => {
+ render(
+
+ )
+
+ const label = screen.getByText(
+ `Units of ${grape.name} required: ${integerString(
+ GRAPES_REQUIRED_FOR_WINE
+ )} (available: ${integerString(quantity)})`
+ )
+
+ expect(label).toBeInTheDocument()
+ })
+
+ test.each([
+ {
+ grape: grapeChardonnay,
+ },
+ {
+ grape: grapeSauvignonBlanc,
+ },
+ {
+ grape: grapeNebbiolo,
+ },
+ ])('shows yeast requirements for $grape.wineId', ({ grape }) => {
+ const yeastQuantity = getYeastRequiredForWine(grape.variety)
+
+ render(
+
+ )
+
+ const label = screen.getByText(
+ `Units of ${yeast.name} required: ${integerString(
+ getYeastRequiredForWine(grape.variety)
+ )} (available: ${integerString(yeastQuantity)})`
+ )
+
+ expect(label).toBeInTheDocument()
+ })
+
+ test.each([
+ {
+ grape: grapeChardonnay,
+ quantity: 0,
+ },
+ {
+ grape: grapeSauvignonBlanc,
+ quantity: 1,
+ },
+ {
+ grape: grapeNebbiolo,
+ quantity: 10,
+ },
+ ])(
+ 'shows that there are already $quantity units of $grape.wineId in cellar',
+ ({ grape, quantity }) => {
+ render(
+
+ )
+
+ const label = screen.getByText(`In cellar: ${integerString(quantity)}`)
+
+ expect(label).toBeInTheDocument()
+ }
+ )
+
+ test.each([
+ { grape: grapeChardonnay, grapeQuantity: 0, yeastQuantity: 0 },
+ {
+ grape: grapeChardonnay,
+ grapeQuantity: GRAPES_REQUIRED_FOR_WINE - 1,
+ yeastQuantity: getYeastRequiredForWine(grapeChardonnay.variety),
+ },
+ {
+ grape: grapeChardonnay,
+ grapeQuantity: GRAPES_REQUIRED_FOR_WINE,
+ yeastQuantity: getYeastRequiredForWine(grapeChardonnay.variety) - 1,
+ },
+ ])(
+ 'disables "Make" button when there are insufficient ingredients ($grape.id: $grapeQuantity, yeast: $yeastQuantity)',
+ ({ grape, grapeQuantity, yeastQuantity }) => {
+ render(
+
+ )
+
+ const makeButton = screen.getByText('Make')
+
+ expect(makeButton).toBeDisabled()
+ }
+ )
+
+ test.each([
+ {
+ grape: grapeChardonnay,
+ grapeQuantity: GRAPES_REQUIRED_FOR_WINE,
+ yeastQuantity: getYeastRequiredForWine(grapeChardonnay.variety),
+ },
+ ])(
+ 'enables "Make" button when there are sufficient ingredients ($grape.id: $grapeQuantity, yeast: $yeastQuantity)',
+ ({ grape, grapeQuantity, yeastQuantity }) => {
+ render(
+
+ )
+
+ const makeButton = screen.getByText('Make')
+
+ expect(makeButton).toBeEnabled()
+ }
+ )
+
+ test.each([{ wineYield: 1 }, { wineYield: 2 }])(
+ 'shows yeast requirements for $wineYield wines',
+ ({ wineYield }) => {
+ const grape = grapeChardonnay
+
+ const yeastQuantity = getYeastRequiredForWine(grape.variety) * wineYield
+ const grapeQuantity = GRAPES_REQUIRED_FOR_WINE * wineYield
+
+ render(
+
+ )
+
+ const input = screen.getByPlaceholderText(QUANTITY_INPUT_PLACEHOLDER_TEXT)
+
+ userEvent.type(input, String(wineYield))
+
+ const label = screen.getByText(
+ `Units of ${yeast.name} required: ${integerString(
+ getYeastRequiredForWine(grape.variety) * wineYield
+ )} (available: ${integerString(yeastQuantity)})`
+ )
+
+ expect(label).toBeInTheDocument()
+ }
+ )
+})
diff --git a/src/components/WineRecipeList/WineRecipeList.js b/src/components/WineRecipeList/WineRecipeList.js
new file mode 100644
index 000000000..77f782bec
--- /dev/null
+++ b/src/components/WineRecipeList/WineRecipeList.js
@@ -0,0 +1,38 @@
+import React, { useContext } from 'react'
+
+import { getWineVarietiesAvailableToMake } from '../../utils/getWineVarietiesAvailableToMake'
+import FarmhandContext from '../Farmhand/Farmhand.context'
+import { grapeVariety } from '../../enums'
+
+import { WineRecipe } from './WineRecipe'
+
+const totalGrapeVarieties = Object.keys(grapeVariety).length
+
+export const WineRecipeList = () => {
+ const {
+ gameState: { itemsSold },
+ } = useContext(FarmhandContext)
+
+ const wineVarietiesAvailableToMake = getWineVarietiesAvailableToMake(
+ itemsSold
+ )
+
+ const numberOfWineVarietiesAvailableToMake =
+ wineVarietiesAvailableToMake.length
+
+ return (
+ <>
+
+ Available Wine Recipes ({numberOfWineVarietiesAvailableToMake} /{' '}
+ {totalGrapeVarieties})
+
+
+ {wineVarietiesAvailableToMake.map(wineVariety => (
+ -
+
+
+ ))}
+
+ >
+ )
+}
diff --git a/src/constants.js b/src/constants.js
index c0ea088a8..a111f9542 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -284,6 +284,8 @@ export const WEEDS_SPAWN_CHANCE = 0.15
export const KEG_INTEREST_RATE = 0.02
export const KEG_SPOILAGE_RATE_MULTIPLIER = 0.001
+export const WINE_INTEREST_RATE = 0.015
+export const WINE_GROWTH_TIMELINE_CAP = 100
// NOTE: not all of these are implemented yet, these are for all the currently
// planned experience rewards
@@ -316,3 +318,7 @@ export const Z_INDEX = {
}
export const HEARTBEAT_INTERVAL_PERIOD = 10 * 1000 // 10 seconds
+
+export const GRAPES_REQUIRED_FOR_WINE = 50
+
+export const YEAST_REQUIREMENT_FOR_WINE_MULTIPLIER = 5
diff --git a/src/data/__mocks__/maps.js b/src/data/__mocks__/maps.js
index effa3c294..4d5648bea 100644
--- a/src/data/__mocks__/maps.js
+++ b/src/data/__mocks__/maps.js
@@ -9,6 +9,7 @@ export const recipeCategories = {
[recipeType.KITCHEN]: {},
[recipeType.FORGE]: {},
[recipeType.RECYCLING]: {},
+ [recipeType.WINE]: {},
}
export const recipesMap = {}
diff --git a/src/data/crop.js b/src/data/crop.js
index 13d9a7bac..b09cf81bf 100644
--- a/src/data/crop.js
+++ b/src/data/crop.js
@@ -1,4 +1,7 @@
-/** @typedef {import("../index").farmhand.item} farmhand.item */
+/**
+ * @typedef {import("../index").farmhand.item} farmhand.item
+ * @typedef {import("../index").farmhand.cropVariety} farmhand.cropVariety
+ */
import { fieldMode, itemType } from '../enums'
import { getCropLifecycleDuration } from '../utils/getCropLifecycleDuration'
@@ -6,13 +9,13 @@ import { getCropLifecycleDuration } from '../utils/getCropLifecycleDuration'
const { freeze } = Object
/**
- * @param {farmhand.item} item
+ * @param {Partial} item
* @returns {farmhand.item}
*/
export const crop = ({
cropTimeline,
growsInto,
- tier,
+ tier = 1,
isSeed = Boolean(growsInto),
cropLifecycleDuration = getCropLifecycleDuration({ cropTimeline }),
@@ -58,3 +61,16 @@ export const fromSeed = (
}),
}
}
+
+/**
+ * @param {farmhand.cropVariety} cropVariety
+ * @returns {farmhand.cropVariety}
+ */
+export const cropVariety = ({
+ imageId,
+ cropFamily,
+ variety,
+ ...cropVarietyProperties
+}) => {
+ return { imageId, cropFamily, variety, ...crop({ ...cropVarietyProperties }) }
+}
diff --git a/src/data/crops/grape.js b/src/data/crops/grape.js
index 0a64c7d5a..05c1a474f 100644
--- a/src/data/crops/grape.js
+++ b/src/data/crops/grape.js
@@ -1,8 +1,33 @@
/** @typedef {import("../../index").farmhand.item} farmhand.item */
+/** @typedef {import("../../index").farmhand.grape} farmhand.grape */
/** @typedef {import("../../index").farmhand.cropVariety} farmhand.cropVariety */
-import { crop, fromSeed } from '../crop'
-import { cropType } from '../../enums'
+import { crop, fromSeed, cropVariety } from '../crop'
+import { cropFamily, cropType, grapeVariety } from '../../enums'
+
+/**
+ * @param {farmhand.item | farmhand.cropVariety} item
+ * @returns {item is farmhand.grape}
+ */
+export const isGrape = item => {
+ return 'cropFamily' in item && item.cropFamily === cropFamily.GRAPE
+}
+
+/**
+ * @param {Omit & { wineId: string }} grapeProps
+ * @returns {farmhand.grape}
+ */
+const grape = grapeProps => {
+ const newGrape = {
+ ...cropVariety({ ...grapeProps, cropFamily: cropFamily.GRAPE }),
+ }
+
+ if (!isGrape(newGrape)) {
+ throw new Error(`Invalid cropVariety props`)
+ }
+
+ return newGrape
+}
/**
* @property farmhand.module:items.grapeSeed
@@ -28,112 +53,181 @@ export const grapeSeed = crop({
tier: 7,
})
+/**
+ * @type {Record}
+ */
+export const grapeVarietyNameMap = {
+ [grapeVariety.CHARDONNAY]: 'Chardonnay',
+ [grapeVariety.SAUVIGNON_BLANC]: 'Sauvignon Blanc',
+ //[grapeVariety.PINOT_BLANC]: 'Pinot Blanc',
+ //[grapeVariety.MUSCAT]: 'Muscat',
+ //[grapeVariety.RIESLING]: 'Riesling',
+ //[grapeVariety.MERLOT]: 'Merlot',
+ [grapeVariety.CABERNET_SAUVIGNON]: 'Cabernet Sauvignon',
+ //[grapeVariety.SYRAH]: 'Syrah',
+ [grapeVariety.TEMPRANILLO]: 'Tempranillo',
+ [grapeVariety.NEBBIOLO]: 'Nebbiolo',
+}
+
+/**
+ * @type {Record} The number value represents a wine's
+ * value relative to a baseline of 1. Must be an integer.
+ */
+export const wineVarietyValueMap = {
+ [grapeVariety.CHARDONNAY]: 1,
+ [grapeVariety.SAUVIGNON_BLANC]: 8,
+ //[grapeVariety.PINOT_BLANC]: 2,
+ //[grapeVariety.MUSCAT]: 4,
+ //[grapeVariety.RIESLING]: 7,
+ //[grapeVariety.MERLOT]: 6,
+ [grapeVariety.CABERNET_SAUVIGNON]: 3,
+ //[grapeVariety.SYRAH]: 9,
+ [grapeVariety.TEMPRANILLO]: 5,
+ [grapeVariety.NEBBIOLO]: 10,
+}
+
/**
* @property farmhand.module:items.grapeChardonnay
- * @type {farmhand.cropVariety}
+ * @type {farmhand.grape}
*/
-export const grapeChardonnay = crop({
+export const grapeChardonnay = grape({
...fromSeed(grapeSeed, {
- variantIdx: grapeSeed.growsInto.indexOf('grape-chardonnay'),
+ variantIdx: grapeSeed.growsInto?.indexOf('grape-chardonnay'),
}),
name: 'Chardonnay Grape',
imageId: 'grape-green',
+ variety: grapeVariety.CHARDONNAY,
+ wineId: 'wine-chardonnay',
})
/**
* @property farmhand.module:items.grapeSauvignonBlanc
- * @type {farmhand.cropVariety}
+ * @type {farmhand.grape}
*/
-export const grapeSauvignonBlanc = crop({
+export const grapeSauvignonBlanc = grape({
...fromSeed(grapeSeed, {
- variantIdx: grapeSeed.growsInto.indexOf('grape-sauvignon-blanc'),
+ variantIdx: grapeSeed.growsInto?.indexOf('grape-sauvignon-blanc'),
}),
name: 'Sauvignon Blanc Grape',
imageId: 'grape-green',
+ variety: grapeVariety.SAUVIGNON_BLANC,
+ wineId: 'wine-sauvignon-blanc',
})
/**
* @property farmhand.module:items.grapePinotBlanc
- * @type {farmhand.cropVariety}
+ * @type {farmhand.grape}
*/
-// export const grapePinotBlanc = crop({
-// ...fromSeed(grapeSeed, { variantIdx: grapeSeed.growsInto.indexOf('grape-pinot-blanc') }),
+// export const grapePinotBlanc = grape({
+// ...fromSeed(grapeSeed, { variantIdx: grapeSeed.growsInto?.indexOf('grape-pinot-blanc') }),
// name: 'Pinot Blanc Grape',
// imageId: 'grape-green',
+// variety: grapeVariety.PINOT_BLANC,
+// wineId: 'wine-pinot-blanc',
// })
/**
* @property farmhand.module:items.grapeMuscat
- * @type {farmhand.cropVariety}
+ * @type {farmhand.grape}
*/
-// export const grapeMuscat = crop({
-// ...fromSeed(grapeSeed, { variantIdx: grapeSeed.growsInto.indexOf('grape-muscat') }),
+// export const grapeMuscat = grape({
+// ...fromSeed(grapeSeed, { variantIdx: grapeSeed.growsInto?.indexOf('grape-muscat') }),
// name: 'Muscat Grape',
// imageId: 'grape-green',
+// variety: grapeVariety.MUSCAT,
+// wineId: 'wine-muscat',
// })
/**
* @property farmhand.module:items.grapeRiesling
- * @type {farmhand.cropVariety}
+ * @type {farmhand.grape}
*/
-// export const grapeRiesling = crop({
-// ...fromSeed(grapeSeed, { variantIdx: grapeSeed.growsInto.indexOf('grape-riesling') }),
+// export const grapeRiesling = grape({
+// ...fromSeed(grapeSeed, { variantIdx: grapeSeed.growsInto?.indexOf('grape-riesling') }),
// name: 'Riesling Grape',
// imageId: 'grape-green',
+// variety: grapeVariety.RIESLING,
+// wineId: 'wine-riesling',
// })
/**
* @property farmhand.module:items.grapeMerlot
- * @type {farmhand.cropVariety}
+ * @type {farmhand.grape}
*/
-// export const grapeMerlot = crop({
-// ...fromSeed(grapeSeed, { variantIdx: grapeSeed.growsInto.indexOf('grape-merlot') }),
+// export const grapeMerlot = grape({
+// ...fromSeed(grapeSeed, { variantIdx: grapeSeed.growsInto?.indexOf('grape-merlot') }),
// name: 'Merlot Grape',
// imageId: 'grape-purple',
+// variety: grapeVariety.MERLOT,
+// wineId: 'wine-merlot',
// })
/**
* @property farmhand.module:items.grapeCabernetSauvignon
- * @type {farmhand.cropVariety}
+ * @type {farmhand.grape}
*/
-export const grapeCabernetSauvignon = crop({
+export const grapeCabernetSauvignon = grape({
...fromSeed(grapeSeed, {
- variantIdx: grapeSeed.growsInto.indexOf('grape-cabernet-sauvignon'),
+ variantIdx: grapeSeed.growsInto?.indexOf('grape-cabernet-sauvignon'),
}),
name: 'Cabernet Sauvignon Grape',
imageId: 'grape-purple',
+ variety: grapeVariety.CABERNET_SAUVIGNON,
+ wineId: 'wine-cabernet-sauvignon',
})
/**
* @property farmhand.module:items.grapeSyrah
- * @type {farmhand.cropVariety}
+ * @type {farmhand.grape}
*/
-// export const grapeSyrah = crop({
-// ...fromSeed(grapeSeed, { variantIdx: grapeSeed.growsInto.indexOf('grape-syrah') }),
+// export const grapeSyrah = grape({
+// ...fromSeed(grapeSeed, { variantIdx: grapeSeed.growsInto?.indexOf('grape-syrah') }),
// name: 'Syrah Grape',
// imageId: 'grape-purple',
+// variety: grapeVariety.SYRAH,
+// wineId: 'wine-syrah',
// })
/**
* @property farmhand.module:items.grapeTempranillo
- * @type {farmhand.cropVariety}
+ * @type {farmhand.grape}
*/
-export const grapeTempranillo = crop({
+export const grapeTempranillo = grape({
...fromSeed(grapeSeed, {
- variantIdx: grapeSeed.growsInto.indexOf('grape-tempranillo'),
+ variantIdx: grapeSeed.growsInto?.indexOf('grape-tempranillo'),
}),
name: 'Tempranillo Grape',
imageId: 'grape-purple',
+ variety: grapeVariety.TEMPRANILLO,
+ wineId: 'wine-tempranillo',
})
/**
* @property farmhand.module:items.grapeNebbiolo
- * @type {farmhand.cropVariety}
+ * @type {farmhand.grape}
*/
-export const grapeNebbiolo = crop({
+export const grapeNebbiolo = grape({
...fromSeed(grapeSeed, {
- variantIdx: grapeSeed.growsInto.indexOf('grape-nebbiolo'),
+ variantIdx: grapeSeed.growsInto?.indexOf('grape-nebbiolo'),
}),
name: 'Nebbiolo Grape',
imageId: 'grape-purple',
+ variety: grapeVariety.NEBBIOLO,
+ wineId: 'wine-nebbiolo',
})
+
+/**
+ * @type {Record}
+ */
+export const grapeVarietyToGrapeItemMap = {
+ [grapeVariety.CHARDONNAY]: grapeChardonnay,
+ [grapeVariety.SAUVIGNON_BLANC]: grapeSauvignonBlanc,
+ //[grapeVariety.PINOT_BLANC]: grapePinotBlanc,
+ //[grapeVariety.MUSCAT]: grapeMuscat,
+ //[grapeVariety.RIESLING]: grapeRiesling,
+ //[grapeVariety.MERLOT]: grapeMerlot,
+ [grapeVariety.CABERNET_SAUVIGNON]: grapeCabernetSauvignon,
+ //[grapeVariety.SYRAH]: grapeSyrah,
+ [grapeVariety.TEMPRANILLO]: grapeTempranillo,
+ [grapeVariety.NEBBIOLO]: grapeNebbiolo,
+}
diff --git a/src/data/maps.js b/src/data/maps.js
index cf0bd7ab1..3cd7c7e0d 100644
--- a/src/data/maps.js
+++ b/src/data/maps.js
@@ -37,6 +37,7 @@ export const recipeCategories = {
[recipeType.FORGE]: {},
[recipeType.FERMENTATION]: {},
[recipeType.RECYCLING]: {},
+ [recipeType.WINE]: {},
}
export const recipesMap = {}
diff --git a/src/data/recipes.js b/src/data/recipes.js
index c44d13418..43073f21c 100644
--- a/src/data/recipes.js
+++ b/src/data/recipes.js
@@ -1,14 +1,27 @@
/**
* @module farmhand.recipes
+ * @typedef {import('../index').farmhand.item} farmhand.item
+ * @typedef {import('../index').farmhand.recipe} farmhand.recipe
+ * @typedef {import('../index').farmhand.grape} farmhand.grape
+ * @typedef {import('../index').farmhand.wine} farmhand.wine
*/
import { itemType, fieldMode, recipeType } from '../enums'
-import { RECIPE_INGREDIENT_VALUE_MULTIPLIER } from '../constants'
+import {
+ GRAPES_REQUIRED_FOR_WINE,
+ RECIPE_INGREDIENT_VALUE_MULTIPLIER,
+} from '../constants'
+import { getYeastRequiredForWine } from '../utils/getYeastRequiredForWine'
import * as items from './items'
import baseItemsMap from './items-map'
+import { grapeVarietyNameMap } from './crops/grape'
const itemsMap = { ...baseItemsMap }
+/**
+ * @param {Omit & { type?: string, value?: number }} recipe
+ * @returns {farmhand.recipe}
+ */
const itemify = recipe => {
const item = Object.freeze({
type: itemType.CRAFTED_ITEM,
@@ -30,7 +43,7 @@ const itemify = recipe => {
/**
* @property farmhand.module:recipes.salt
- * @type {farmhand.item}
+ * @type {farmhand.recipe}
*/
export const salt = itemify({
id: 'salt',
@@ -43,6 +56,56 @@ export const salt = itemify({
recipeType: recipeType.KITCHEN,
})
+/**
+ * @property farmhand.module:recipes.flour
+ * @type {farmhand.recipe}
+ */
+export const flour = itemify({
+ id: 'flour',
+ name: 'Flour',
+ ingredients: {
+ [items.wheat.id]: 10,
+ },
+ condition: state => state.itemsSold[items.wheat.id] >= 20,
+ recipeType: recipeType.KITCHEN,
+})
+
+/**
+ * @property farmhand.module:recipes.yeast
+ * @type {farmhand.recipe}
+ */
+export const yeast = itemify({
+ id: 'yeast',
+ name: 'Yeast',
+ ingredients: {
+ [flour.id]: 5,
+ },
+ condition: state => state.itemsSold[flour.id] >= 25,
+ recipeType: recipeType.KITCHEN,
+})
+
+/**
+ * @param {farmhand.grape} grape
+ * @returns {farmhand.wine}
+ */
+const getWineRecipeFromGrape = grape => {
+ return {
+ ...itemify({
+ id: grape.wineId,
+ name: `${grapeVarietyNameMap[grape.variety]} Wine`,
+ type: itemType.CRAFTED_ITEM,
+ ingredients: {
+ [grape.id]: GRAPES_REQUIRED_FOR_WINE,
+ [yeast.id]: getYeastRequiredForWine(grape.variety),
+ },
+ recipeType: recipeType.WINE,
+ // NOTE: This prevents wines from appearing in the Learned Recipes list in the Workshop
+ condition: () => false,
+ }),
+ variety: grape.variety,
+ }
+}
+
/**
* @property farmhand.module:recipes.bread
* @type {farmhand.recipe}
@@ -51,9 +114,11 @@ export const bread = itemify({
id: 'bread',
name: 'Bread',
ingredients: {
- [items.wheat.id]: 15,
+ [flour.id]: 10,
+ [yeast.id]: 5,
},
- condition: state => state.itemsSold[items.wheat.id] >= 30,
+ condition: state =>
+ state.itemsSold[flour.id] >= 30 && state.itemsSold[yeast.id] >= 15,
recipeType: recipeType.KITCHEN,
})
@@ -570,7 +635,7 @@ export const bronzeIngot = itemify({
[items.coal.id]: 5,
},
condition: state =>
- state.purchasedSmelter && state.itemsSold[items.bronzeOre.id] >= 50,
+ state.purchasedSmelter > 0 && state.itemsSold[items.bronzeOre.id] >= 50,
recipeType: recipeType.FORGE,
})
@@ -586,7 +651,7 @@ export const ironIngot = itemify({
[items.coal.id]: 12,
},
condition: state =>
- state.purchasedSmelter && state.itemsSold[items.ironOre.id] >= 50,
+ state.purchasedSmelter > 0 && state.itemsSold[items.ironOre.id] >= 50,
recipeType: recipeType.FORGE,
})
@@ -602,7 +667,7 @@ export const silverIngot = itemify({
[items.coal.id]: 8,
},
condition: state =>
- state.purchasedSmelter && state.itemsSold[items.silverOre.id] >= 50,
+ state.purchasedSmelter > 0 && state.itemsSold[items.silverOre.id] >= 50,
recipeType: recipeType.FORGE,
})
@@ -618,7 +683,7 @@ export const goldIngot = itemify({
[items.coal.id]: 10,
},
condition: state =>
- state.purchasedSmelter && state.itemsSold[items.goldOre.id] >= 50,
+ state.purchasedSmelter > 0 && state.itemsSold[items.goldOre.id] >= 50,
recipeType: recipeType.FORGE,
})
@@ -629,7 +694,7 @@ export const compost = itemify({
[items.weed.id]: 25,
},
condition: state =>
- state.purchasedComposter && state.itemsSold[items.weed.id] >= 100,
+ state.purchasedComposter > 0 && state.itemsSold[items.weed.id] >= 100,
description: 'Can be used to make fertilizer.',
recipeType: recipeType.RECYCLING,
type: itemType.CRAFTED_ITEM,
@@ -646,10 +711,80 @@ export const fertilizer = itemify({
[compost.id]: 10,
},
condition: state =>
- state.purchasedComposter && state.itemsSold[compost.id] >= 10,
+ state.purchasedComposter > 0 && state.itemsSold[compost.id] >= 10,
description: 'Helps crops grow and mature a little faster.',
enablesFieldMode: fieldMode.FERTILIZE,
recipeType: recipeType.RECYCLING,
type: itemType.FERTILIZER,
value: 25,
})
+
+/**
+ * @property farmhand.module:recipes.wineChardonnay
+ */
+export const wineChardonnay = getWineRecipeFromGrape({
+ ...items.grapeChardonnay,
+})
+
+/**
+ * @property farmhand.module:recipes.wineSauvignonBlanc
+ */
+export const wineSauvignonBlanc = getWineRecipeFromGrape({
+ ...items.grapeSauvignonBlanc,
+})
+
+///**
+// * @property farmhand.module:recipes.winePinotBlanc
+// */
+//export const winePinotBlanc = getWineRecipeFromGrape({
+// ...items.grapePinotBlanc,
+//})
+
+///**
+// * @property farmhand.module:recipes.wineMuscat
+// */
+//export const wineMuscat = getWineRecipeFromGrape({
+// ...items.grapeMuscat,
+//})
+
+///**
+// * @property farmhand.module:recipes.wineRiesling
+// */
+//export const wineRiesling = getWineRecipeFromGrape({
+// ...items.grapeRiesling,
+//})
+
+///**
+// * @property farmhand.module:recipes.wineMerlot
+// */
+//export const wineMerlot = getWineRecipeFromGrape({
+// ...items.grapeMerlot,
+//})
+
+/**
+ * @property farmhand.module:recipes.wineCabernetSauvignon
+ */
+export const wineCabernetSauvignon = getWineRecipeFromGrape({
+ ...items.grapeCabernetSauvignon,
+})
+
+///**
+// * @property farmhand.module:recipes.wineSyrah
+// */
+//export const wineSyrah = getWineRecipeFromGrape({
+// ...items.grapeSyrah,
+//})
+
+/**
+ * @property farmhand.module:recipes.wineTempranillo
+ */
+export const wineTempranillo = getWineRecipeFromGrape({
+ ...items.grapeTempranillo,
+})
+
+/**
+ * @property farmhand.module:recipes.wineNebbiolo
+ */
+export const wineNebbiolo = getWineRecipeFromGrape({
+ ...items.grapeNebbiolo,
+})
diff --git a/src/data/shop-inventory.js b/src/data/shop-inventory.js
index 4541c6390..7803907b7 100644
--- a/src/data/shop-inventory.js
+++ b/src/data/shop-inventory.js
@@ -1,3 +1,7 @@
+/**
+ * @typedef {import('../index').farmhand.item} farmhand.item
+ */
+
import {
// Plantable crops
asparagusSeed,
@@ -27,6 +31,7 @@ import {
import { fertilizer } from './recipes'
+/** @type {farmhand.item[]} */
const inventory = [
// Plantable crops
asparagusSeed,
diff --git a/src/enums.js b/src/enums.js
index 38484bbd8..768aad8bf 100644
--- a/src/enums.js
+++ b/src/enums.js
@@ -3,7 +3,10 @@
* @ignore
*/
+// TODO: Eliminate enumify and use raw enums.
+// @see https://jsdoc.app/tags-enum
/**
+ * @deprecated
* @param {Array.} keys
* @returns {Record}
*/
@@ -46,6 +49,7 @@ export const recipeType = enumify([
'FORGE',
'KITCHEN',
'RECYCLING',
+ 'WINE',
])
/**
@@ -176,3 +180,30 @@ export const toolLevel = enumify([
* @enum {string}
*/
export const cowTradeRejectionReason = enumify(['REQUESTED_COW_UNAVAILABLE'])
+
+/**
+ * @property farmhand.module:enums.cropFamily
+ * @readonly
+ * @enum {string}
+ */
+export const cropFamily = {
+ GRAPE: 'GRAPE',
+}
+
+/**
+ * @property farmhand.module:enums.grapeVariety
+ * @readonly
+ * @enum {string}
+ */
+export const grapeVariety = {
+ CHARDONNAY: 'CHARDONNAY',
+ SAUVIGNON_BLANC: 'SAUVIGNON_BLANC',
+ //PINOT_BLANC: 'PINOT_BLANC',
+ //MUSCAT: 'MUSCAT',
+ //RIESLING: 'RIESLING',
+ //MERLOT: 'MERLOT',
+ CABERNET_SAUVIGNON: 'CABERNET_SAUVIGNON',
+ //SYRAH: 'SYRAH',
+ TEMPRANILLO: 'TEMPRANILLO',
+ NEBBIOLO: 'NEBBIOLO',
+}
diff --git a/src/game-logic/reducers/index.js b/src/game-logic/reducers/index.js
index 99f14ed58..b65484425 100644
--- a/src/game-logic/reducers/index.js
+++ b/src/game-logic/reducers/index.js
@@ -26,6 +26,7 @@ export * from './hugCow'
export * from './incrementPlotContentAge'
export * from './makeRecipe'
export * from './makeFermentationRecipe'
+export * from './makeWine'
export * from './minePlot'
export * from './modifyCow'
export * from './modifyFieldPlotAt'
diff --git a/src/game-logic/reducers/makeFermentationRecipe.js b/src/game-logic/reducers/makeFermentationRecipe.js
index 748134bc8..ae01ef37b 100644
--- a/src/game-logic/reducers/makeFermentationRecipe.js
+++ b/src/game-logic/reducers/makeFermentationRecipe.js
@@ -4,12 +4,11 @@
* @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 { cellarService } from '../../services/cellar'
import { addKegToCellarInventory } from './addKegToCellarInventory'
import { decrementItemFromInventory } from './decrementItemFromInventory'
@@ -18,7 +17,7 @@ import { decrementItemFromInventory } from './decrementItemFromInventory'
* @param {state} state
* @param {item} fermentationRecipe
* @param {number} [howMany=1]
- * @returns {farmhand.state}
+ * @returns {state}
*/
export const makeFermentationRecipe = (
state,
@@ -27,7 +26,9 @@ export const makeFermentationRecipe = (
) => {
const { inventory, cellarInventory, purchasedCellar } = state
- const { space: cellarSize } = PURCHASEABLE_CELLARS.get(purchasedCellar)
+ const { space: cellarSize } = PURCHASEABLE_CELLARS.get(purchasedCellar) ?? {
+ space: 0,
+ }
const maxYield = getMaxYieldOfFermentationRecipe(
fermentationRecipe,
@@ -41,12 +42,7 @@ export const makeFermentationRecipe = (
}
for (let i = 0; i < howMany; i++) {
- /** @type keg */
- const keg = {
- id: uuid(),
- itemId: fermentationRecipe.id,
- daysUntilMature: fermentationRecipe.daysToFerment,
- }
+ const keg = cellarService.generateKeg(fermentationRecipe)
state = addKegToCellarInventory(state, keg)
}
diff --git a/src/game-logic/reducers/makeWine.js b/src/game-logic/reducers/makeWine.js
new file mode 100644
index 000000000..c8460d9a9
--- /dev/null
+++ b/src/game-logic/reducers/makeWine.js
@@ -0,0 +1,61 @@
+/**
+ * @typedef {import("../../index").farmhand.item} item
+ * @typedef {import("../../index").farmhand.keg} keg
+ * @typedef {import("../../index").farmhand.grape} grape
+ * @typedef {import("../../components/Farmhand/Farmhand").farmhand.state} state
+ */
+
+import { GRAPES_REQUIRED_FOR_WINE, PURCHASEABLE_CELLARS } from '../../constants'
+import { itemsMap } from '../../data/maps'
+import { cellarService } from '../../services/cellar'
+import { wineService } from '../../services/wine'
+import { getYeastRequiredForWine } from '../../utils/getYeastRequiredForWine'
+
+import { addKegToCellarInventory } from './addKegToCellarInventory'
+import { decrementItemFromInventory } from './decrementItemFromInventory'
+
+/**
+ * @param {state} state
+ * @param {grape} grape
+ * @param {number} [howMany=1]
+ * @returns {state}
+ */
+export const makeWine = (state, grape, howMany = 1) => {
+ const { inventory, cellarInventory, purchasedCellar } = state
+
+ const { space: cellarSize } = PURCHASEABLE_CELLARS.get(purchasedCellar) ?? {
+ space: 0,
+ }
+
+ const maxYield = wineService.getMaxWineYield({
+ grape,
+ inventory,
+ cellarInventory,
+ cellarSize,
+ })
+
+ const wine = itemsMap[grape.wineId]
+ const wineYield = Math.min(howMany, maxYield)
+
+ for (let i = 0; i < wineYield; i++) {
+ const keg = cellarService.generateKeg(wine)
+
+ state = addKegToCellarInventory(state, keg)
+ }
+
+ state = decrementItemFromInventory(
+ state,
+ grape.id,
+ wineYield * GRAPES_REQUIRED_FOR_WINE
+ )
+
+ const yeastRequirements = getYeastRequiredForWine(grape.variety)
+
+ state = decrementItemFromInventory(
+ state,
+ itemsMap.yeast.id,
+ wineYield * yeastRequirements
+ )
+
+ return state
+}
diff --git a/src/game-logic/reducers/makeWine.test.js b/src/game-logic/reducers/makeWine.test.js
new file mode 100644
index 000000000..9a0952e53
--- /dev/null
+++ b/src/game-logic/reducers/makeWine.test.js
@@ -0,0 +1,178 @@
+/**
+ * @typedef {import("../../components/Farmhand/Farmhand").farmhand.state} state
+ */
+
+import { GRAPES_REQUIRED_FOR_WINE } from '../../constants'
+import { grapeChardonnay } from '../../data/crops'
+import { yeast, wineChardonnay } from '../../data/recipes'
+import { cellarService } from '../../services/cellar'
+import { getYeastRequiredForWine } from '../../utils/getYeastRequiredForWine'
+
+import { makeWine } from './makeWine'
+
+const stubKegUuid = 'abc123'
+
+beforeEach(() => {
+ // @ts-expect-error
+ jest.spyOn(cellarService, '_uuid').mockReturnValue(stubKegUuid)
+})
+
+describe('makeWine', () => {
+ test.each([
+ // Insufficient ingredients
+ {
+ /** @type {Partial} */
+ state: {
+ inventory: [],
+ cellarInventory: [],
+ purchasedCellar: 2,
+ },
+ grape: grapeChardonnay,
+ howMany: 1,
+ /** @type {Partial} */
+ expected: {
+ inventory: [],
+ cellarInventory: [],
+ purchasedCellar: 2,
+ },
+ },
+
+ // Ingredients for one wine
+ {
+ /** @type {Partial} */
+ state: {
+ inventory: [
+ { id: grapeChardonnay.id, quantity: GRAPES_REQUIRED_FOR_WINE },
+ {
+ id: yeast.id,
+ quantity: getYeastRequiredForWine(grapeChardonnay.variety),
+ },
+ ],
+ cellarInventory: [],
+ purchasedCellar: 2,
+ },
+ grape: grapeChardonnay,
+ howMany: 1,
+ /** @type {Partial} */
+ expected: {
+ inventory: [],
+ cellarInventory: [
+ {
+ id: stubKegUuid,
+ daysUntilMature: getYeastRequiredForWine(grapeChardonnay.variety),
+ itemId: wineChardonnay.id,
+ },
+ ],
+ purchasedCellar: 2,
+ },
+ },
+
+ // Ingredients for one wine with leftover yeast
+ {
+ /** @type {Partial} */
+ state: {
+ inventory: [
+ { id: grapeChardonnay.id, quantity: GRAPES_REQUIRED_FOR_WINE },
+ {
+ id: yeast.id,
+ quantity: getYeastRequiredForWine(grapeChardonnay.variety) + 1,
+ },
+ ],
+ cellarInventory: [],
+ purchasedCellar: 2,
+ },
+ grape: grapeChardonnay,
+ howMany: 1,
+ /** @type {Partial} */
+ expected: {
+ inventory: [
+ {
+ id: yeast.id,
+ quantity: 1,
+ },
+ ],
+ cellarInventory: [
+ {
+ id: stubKegUuid,
+ daysUntilMature: getYeastRequiredForWine(grapeChardonnay.variety),
+ itemId: wineChardonnay.id,
+ },
+ ],
+ purchasedCellar: 2,
+ },
+ },
+
+ // Ingredients for one wine but requesting more
+ {
+ /** @type {Partial} */
+ state: {
+ inventory: [
+ { id: grapeChardonnay.id, quantity: GRAPES_REQUIRED_FOR_WINE },
+ {
+ id: yeast.id,
+ quantity: getYeastRequiredForWine(grapeChardonnay.variety),
+ },
+ ],
+ cellarInventory: [],
+ purchasedCellar: 2,
+ },
+ grape: grapeChardonnay,
+ howMany: 10,
+ /** @type {Partial} */
+ expected: {
+ inventory: [],
+ cellarInventory: [
+ {
+ id: stubKegUuid,
+ daysUntilMature: getYeastRequiredForWine(grapeChardonnay.variety),
+ itemId: wineChardonnay.id,
+ },
+ ],
+ purchasedCellar: 2,
+ },
+ },
+
+ // Ingredients for multiple wines but requesting more
+ {
+ /** @type {Partial} */
+ state: {
+ inventory: [
+ { id: grapeChardonnay.id, quantity: GRAPES_REQUIRED_FOR_WINE * 2 },
+ {
+ id: yeast.id,
+ quantity: getYeastRequiredForWine(grapeChardonnay.variety) * 2,
+ },
+ ],
+ cellarInventory: [],
+ purchasedCellar: 2,
+ },
+ grape: grapeChardonnay,
+ howMany: 10,
+ /** @type {Partial} */
+ expected: {
+ inventory: [],
+ cellarInventory: [
+ {
+ id: stubKegUuid,
+ daysUntilMature: getYeastRequiredForWine(grapeChardonnay.variety),
+ itemId: wineChardonnay.id,
+ },
+ {
+ id: stubKegUuid,
+ daysUntilMature: getYeastRequiredForWine(grapeChardonnay.variety),
+ itemId: wineChardonnay.id,
+ },
+ ],
+ purchasedCellar: 2,
+ },
+ },
+ ])(
+ 'makes $expected.cellarInventory.length wine unit(s) based on $state.inventory.0.id: $state.inventory.0.quantity, $state.inventory.1.id: $state.inventory.1.quantity',
+ ({ state, grape, howMany, expected }) => {
+ // @ts-expect-error
+ const result = makeWine(state, grape, howMany)
+
+ expect(result).toEqual(expected)
+ }
+ )
+})
diff --git a/src/game-logic/reducers/processCellarSpoilage.js b/src/game-logic/reducers/processCellarSpoilage.js
index a7f98c54f..3e4f6d5c4 100644
--- a/src/game-logic/reducers/processCellarSpoilage.js
+++ b/src/game-logic/reducers/processCellarSpoilage.js
@@ -1,6 +1,8 @@
/** @typedef {import("../../components/Farmhand/Farmhand").farmhand.state} state */
import { randomNumberService } from '../../common/services/randomNumber'
+import { itemsMap } from '../../data/maps'
+import { wineService } from '../../services/wine'
import { KEG_SPOILED_MESSAGE } from '../../templates'
import { getKegSpoilageRate } from '../../utils/getKegSpoilageRate'
@@ -8,7 +10,7 @@ import { removeKegFromCellar } from './removeKegFromCellar'
/**
* @param {state} state
- * @returns state
+ * @returns {state}
*/
export const processCellarSpoilage = state => {
const { cellarInventory } = state
@@ -17,15 +19,20 @@ export const processCellarSpoilage = state => {
for (let i = newCellarInventory.length - 1; i > -1; i--) {
const keg = newCellarInventory[i]
+ const kegItem = itemsMap[keg.itemId]
const spoilageRate = getKegSpoilageRate(keg)
- if (randomNumberService.isRandomNumberLessThan(spoilageRate)) {
+ if (
+ !wineService.isWineRecipe(kegItem) &&
+ randomNumberService.isRandomNumberLessThan(spoilageRate)
+ ) {
state = removeKegFromCellar(state, keg.id)
state = {
...state,
newDayNotifications: [
...state.newDayNotifications,
{
+ // @ts-expect-error
message: KEG_SPOILED_MESSAGE`${keg}`,
severity: 'error',
},
diff --git a/src/game-logic/reducers/processCellarSpoilage.test.js b/src/game-logic/reducers/processCellarSpoilage.test.js
index 20b3d19c9..3f8daec36 100644
--- a/src/game-logic/reducers/processCellarSpoilage.test.js
+++ b/src/game-logic/reducers/processCellarSpoilage.test.js
@@ -1,4 +1,5 @@
import { randomNumberService } from '../../common/services/randomNumber'
+import { wineTempranillo } from '../../data/recipes'
import { KEG_SPOILED_MESSAGE } from '../../templates'
import { getKegStub } from '../../test-utils/stubs/getKegStub'
@@ -13,6 +14,8 @@ describe('processCellarSpoilage', () => {
const keg = getKegStub()
const cellarInventory = [keg]
const newDayNotifications = []
+
+ // @ts-expect-error
const expectedState = processCellarSpoilage({
cellarInventory,
newDayNotifications,
@@ -29,6 +32,8 @@ describe('processCellarSpoilage', () => {
const keg = getKegStub()
const cellarInventory = [keg]
const newDayNotifications = []
+
+ // @ts-expect-error
const expectedState = processCellarSpoilage({
cellarInventory,
newDayNotifications,
@@ -37,6 +42,24 @@ describe('processCellarSpoilage', () => {
expect(expectedState.cellarInventory).toHaveLength(0)
})
+ test('does not remove wine keg', () => {
+ jest
+ .spyOn(randomNumberService, 'isRandomNumberLessThan')
+ .mockReturnValueOnce(true)
+
+ const keg = getKegStub({ itemId: wineTempranillo.id })
+ const cellarInventory = [keg]
+ const newDayNotifications = []
+
+ // @ts-expect-error
+ const expectedState = processCellarSpoilage({
+ cellarInventory,
+ newDayNotifications,
+ })
+
+ expect(expectedState.cellarInventory).toHaveLength(1)
+ })
+
test('shows notification for kegs that have spoiled', () => {
jest
.spyOn(randomNumberService, 'isRandomNumberLessThan')
@@ -45,6 +68,8 @@ describe('processCellarSpoilage', () => {
const keg = getKegStub()
const cellarInventory = [keg]
const newDayNotifications = []
+
+ // @ts-expect-error
const expectedState = processCellarSpoilage({
cellarInventory,
newDayNotifications,
@@ -52,6 +77,7 @@ describe('processCellarSpoilage', () => {
expect(expectedState.newDayNotifications).toEqual([
{
+ // @ts-expect-error
message: KEG_SPOILED_MESSAGE`${keg}`,
severity: 'error',
},
diff --git a/src/game-logic/reducers/sellItem.js b/src/game-logic/reducers/sellItem.js
index 43266fe8b..78d027093 100644
--- a/src/game-logic/reducers/sellItem.js
+++ b/src/game-logic/reducers/sellItem.js
@@ -1,3 +1,8 @@
+/**
+ * @typedef {import("../../components/Farmhand/Farmhand").farmhand.state} farmhand.state
+ * @typedef {import("../../").farmhand.item} farmhand.item
+ */
+
import { itemsMap } from '../../data/maps'
import { isItemAFarmProduct } from '../../utils/isItemAFarmProduct'
import {
diff --git a/src/game-logic/reducers/sellItem.test.js b/src/game-logic/reducers/sellItem.test.js
index fe16462a0..4241fb3e0 100644
--- a/src/game-logic/reducers/sellItem.test.js
+++ b/src/game-logic/reducers/sellItem.test.js
@@ -219,6 +219,7 @@ describe('sellItem', () => {
test('payoff notification is shown', () => {
expect(state.todaysNotifications).toEqual([
+ // @ts-expect-error
{ message: LOAN_PAYOFF``, severity: 'success' },
])
})
diff --git a/src/game-logic/reducers/sellKeg.js b/src/game-logic/reducers/sellKeg.js
index 60a4450b3..b05c137c9 100644
--- a/src/game-logic/reducers/sellKeg.js
+++ b/src/game-logic/reducers/sellKeg.js
@@ -78,6 +78,7 @@ export const sellKeg = (state, keg) => {
// NOTE: This notification will need to be revisited to support Wine sales.
state = prependPendingPeerMessage(
state,
+ // @ts-expect-error
SOLD_FERMENTED_ITEM_PEER_NOTIFICATION`${item}`,
'warning'
)
diff --git a/src/game-logic/reducers/sellKeg.test.js b/src/game-logic/reducers/sellKeg.test.js
index c1dec67b1..79fec80ac 100644
--- a/src/game-logic/reducers/sellKeg.test.js
+++ b/src/game-logic/reducers/sellKeg.test.js
@@ -10,7 +10,7 @@ import { getKegValue } from '../../utils/getKegValue'
import { sellKeg } from './sellKeg'
/** @type keg */
-const stubKeg = { id: 'stub-keg', daysUntilMature: 4, itemId: carrot.id }
+const stubKeg = { id: 'stub-keg', daysUntilMature: -4, itemId: carrot.id }
const stubKegValue = getKegValue(stubKeg)
diff --git a/src/handlers/ui-events.js b/src/handlers/ui-events.js
index 74ad77b69..691b6a71b 100644
--- a/src/handlers/ui-events.js
+++ b/src/handlers/ui-events.js
@@ -2,6 +2,8 @@
* @typedef {import("../index").farmhand.item} item
* @typedef {import("../index").farmhand.keg} keg
* @typedef {import("../index").farmhand.cow} farmhand.cow
+ * @typedef {import("../index").farmhand.recipe} farmhand.recipe
+ * @typedef {import("../index").farmhand.grape} grape
*/
import { saveAs } from 'file-saver'
import window from 'global/window'
@@ -12,7 +14,13 @@ import {
transformStateDataForImport,
} from '../utils'
import { DEFAULT_ROOM, TOOLBELT_FIELD_MODES } from '../constants'
-import { dialogView, fieldMode, stageFocusType } from '../enums'
+import {
+ dialogView,
+ fieldMode,
+ // eslint-disable-next-line no-unused-vars
+ grapeVariety,
+ stageFocusType,
+} from '../enums'
import {
DISCONNECTING_FROM_SERVER,
INVALID_DATA_PROVIDED,
@@ -68,6 +76,14 @@ export default {
this.makeFermentationRecipe(fermentationRecipe, howMany)
},
+ /**
+ * @param {grape} grape
+ * @param {number} [howMany=1]
+ */
+ handleMakeWineClick(grape, howMany = 1) {
+ this.makeWine(grape, howMany)
+ },
+
/**
* @param {keg} keg
*/
@@ -114,6 +130,7 @@ export default {
* @param {React.SyntheticEvent} e
* @param {farmhand.cow} cow
*/
+ // @ts-ignore
handleCowAutomaticHugChange({ target: { checked } }, cow) {
this.changeCowAutomaticHugState(cow, checked)
},
diff --git a/src/img/dishes/flour.piskel b/src/img/dishes/flour.piskel
new file mode 100644
index 000000000..262c97492
--- /dev/null
+++ b/src/img/dishes/flour.piskel
@@ -0,0 +1 @@
+{"modelVersion":2,"piskel":{"name":"flour","description":"","fps":12,"height":24,"width":24,"layers":["{\"name\":\"Layer 1\",\"opacity\":1,\"frameCount\":1,\"chunks\":[{\"layout\":[[0]],\"base64PNG\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAEHUlEQVRIS7WVa2xTZRjH/6f30+vapVrqVl0ZSGKy6EQgDkwIypiaMYIfiEVjYoiXzZJM/KRRNOGTQuKcRickLtuIkhg2vCBREwNz04wwwiLboHRu3Uq30Z6ul9Pb1mPet+uxtzXGhPfLOee9/H//53ne9z0M7nJj7rI+ygGEMvD/so7OKZwoip5o2wOUQjBAe+dPuexcDeHtl5+iY8dO/Uz18wZbm+uw3malEwpEREEKJo3AC2BZ8VIAoVA86LkJQRCgUcsQ5ZfBMAz9Nto2/gtrXYUBuOxaQE1VZZ773BSJAOKciLMqKeKJZaTTGb3LP3Rg8zNOUZwAK6o34PnGetpXSrwsgGEEUZxMVLMyLHFByFVa+s7HltH14VsI8bwInZr14/SFK3m1zdZAaGuug91mpbn/5QsnHm1qy3OejIehUOloHwF8130MBg2LpWgMo+MzyIqfaGtCe+d5EVJU5FHXHQxd99I0EVG5Ukvzn5smUovAeD9GxyZp/3NHTtEnMTkXSODsIO0v2qaZOlRbccvjxafnrlFIthHRwpYtOBG+HYyDVSpQ/2BVdgcWA/Y2bMQTD9sR4WNY8HMUUq4RQ1w0BZNejRqrGdO+AOYWw/j24njpCN5xbAUXjmFrbSUWE0oEOA6sQorbgQjs1VbEoiGwGj3MygT+dPmxzqSl326Pl45fnfTgkU3Va0fwek6KaKpm5rDedh+21degUhFAx5nr0Go00LBK8LEEGEYCsykDIKZuzPMYm1rEwJC7dASH92+hB9SkY3FglwX+pAnf/3oVLzbZUHHPBKZmGzB8xU3BjpbtFNr94zSWIlFqhNSudnUnlixy42N2bN5gxtO76mGvGkJwYRMCSRN9d882oK//Eox6HWQyKQ48ac1A5xowOPwXLDoJFiJpjNyYxze/TZSOwLl/C6QQ8EqLHRX3TqLrtAFqNYuDe33oHbDQ4u/e8RB6zl7CwX07KLizW0nrRu6wWzNe1N6fOUtrbtNttZXwhtJIplI45ODxeZ8erzpC6O234NnHDTCYM65/H3HjhX0+9A1YsJKKw6JlaAQTngBOnh8rAgg9b+7EpI+HscJInRBHjuaMcxJB3zkLXNNevOeU4GjHCo46pej62kgLnkzEoVCqxHWk6KtbXLyuhZ72nRh2+WE1aSGXybFOL8HuRi4DaPHh+EkF1KwSrznC+PgrNQ6/xOOzXh0MOg0tLtl1JIVaNUt3VRHgeOsecEFOjIDcS8T5R18qcORQkqaCRPbuGxK835GmkXzwSZpGGorw0GvVVDgcFzDPRXBhZIqmSbzsSIr+uOmnByZ7cMiTOBsc+xvb6x6gAG8gDqtJBc8dHhKJFCtpAaxCBj6RglopR4iPg48ncfHabB6AfJT7B5e9MtYYLPlP/j9CZdf8Az/h7SitknPUAAAAAElFTkSuQmCC\"}]}"]}}
\ No newline at end of file
diff --git a/src/img/dishes/flour.png b/src/img/dishes/flour.png
new file mode 100644
index 000000000..d95fe63ed
Binary files /dev/null and b/src/img/dishes/flour.png differ
diff --git a/src/img/dishes/yeast.piskel b/src/img/dishes/yeast.piskel
new file mode 100644
index 000000000..c3eaffcde
--- /dev/null
+++ b/src/img/dishes/yeast.piskel
@@ -0,0 +1 @@
+{"modelVersion":2,"piskel":{"name":"yeast","description":"","fps":12,"height":24,"width":24,"layers":["{\"name\":\"Layer 1\",\"opacity\":1,\"frameCount\":1,\"chunks\":[{\"layout\":[[0]],\"base64PNG\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAABbklEQVRIS+2Uv0vDQBTHvwc6RAIhOJToZinURYiC+RdchG7FvbropH+HTrpId+mmuPRfiKABFwNSN00dJBSCGRRP7poLaS6/sHQQfVtyl8/nvXe5RzDjIDPm419Q2uG/1SKa6kel6qts4mCntw0aKQgBzPa18BUy8hbjbDcOG+huNpGVfufGxe3JY7IwiZcloNZRA4zoe+/otdbjzAXp5XWEpZrGL1H76g66sQD2YB9z2QQzLeDwwAvxQSmHPw/HsLwQknlCoBqKJEkK6NaZCX8QYOSFuGiZuZmnZexMdi4daIYCva6iv+/ElUwKTk3+rf8U4JNSdK1VqfdJOGvVck1Dx37AHCHQV1S+3D8oEQjI2yDAF7JFLLNd2+UNX6yPwSIqC5IiaSxSGVwqsPaa0NeU0vlStMG/D2Gfu5lnwF7SaSRpuPTPRpn9SJIFzxPwSkQbWEVFEbVDbKl0k9O89JSQrkFRAlWG3VSH/vsF3ysBgRmuWgXeAAAAAElFTkSuQmCC\"}]}"]}}
\ No newline at end of file
diff --git a/src/img/dishes/yeast.png b/src/img/dishes/yeast.png
new file mode 100644
index 000000000..f18c21445
Binary files /dev/null and b/src/img/dishes/yeast.png differ
diff --git a/src/img/index.js b/src/img/index.js
index 8a1859f9c..9a3d3739e 100644
--- a/src/img/index.js
+++ b/src/img/index.js
@@ -1,3 +1,5 @@
+import { grapeVariety } from '../enums'
+
// Plot states
import wateredPlot from './plot-states/watered-plot.png'
import fertilizedPlot from './plot-states/fertilized-plot.png'
@@ -14,6 +16,7 @@ import chicknPotPie from './dishes/chickn-pot-pie.png'
import chocolate from './dishes/chocolate.png'
import garlicBread from './dishes/garlic-bread.png'
import garlicFries from './dishes/garlic-fries.png'
+import flour from './dishes/flour.png'
import frenchOnionSoup from './dishes/french-onion-soup.png'
import friedTofu from './dishes/fried-tofu.png'
import jackolantern from './items/jackolantern.png'
@@ -37,6 +40,11 @@ import spaghetti from './dishes/spaghetti.png'
import strawberryJam from './dishes/strawberry-jam.png'
import tofu from './dishes/tofu.png'
import vegetableOil from './dishes/vegetable-oil.png'
+import yeast from './dishes/yeast.png'
+
+// Wine recipes
+import wineGreen from './wines/wine-green.png'
+import winePurple from './wines/wine-purple.png'
// Crops
import asparagus from './items/asparagus.png'
@@ -236,6 +244,7 @@ export const craftedItems = {
'fried-tofu': friedTofu,
'garlic-bread': garlicBread,
'garlic-fries': garlicFries,
+ flour,
jackolantern,
'hot-sauce': hotSauce,
'olive-oil': oliveOil,
@@ -256,6 +265,7 @@ export const craftedItems = {
'sweet-potato-pie': sweetPotatoPie,
tofu,
'vegetable-oil': vegetableOil,
+ yeast,
...smeltedItems,
}
@@ -415,6 +425,22 @@ export const items = {
...craftedItems,
}
+/**
+ * @type {Record}
+ */
+export const wines = {
+ [grapeVariety.CHARDONNAY]: wineGreen,
+ [grapeVariety.SAUVIGNON_BLANC]: wineGreen,
+ //[grapeVariety.PINOT_BLANC]: wineGreen,
+ //[grapeVariety.MUSCAT]: wineGreen,
+ //[grapeVariety.RIESLING]: wineGreen,
+ //[grapeVariety.MERLOT]: winePurple,
+ [grapeVariety.CABERNET_SAUVIGNON]: winePurple,
+ //[grapeVariety.SYRAH]: winePurple,
+ [grapeVariety.TEMPRANILLO]: winePurple,
+ [grapeVariety.NEBBIOLO]: winePurple,
+}
+
export const tools = {
hoe,
scythe,
diff --git a/src/img/wines/wine-green.piskel b/src/img/wines/wine-green.piskel
new file mode 100644
index 000000000..62433641a
--- /dev/null
+++ b/src/img/wines/wine-green.piskel
@@ -0,0 +1 @@
+{"modelVersion":2,"piskel":{"name":"wine-green","description":"","fps":12,"height":24,"width":24,"layers":["{\"name\":\"Layer 1\",\"opacity\":1,\"frameCount\":1,\"chunks\":[{\"layout\":[[0]],\"base64PNG\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAABLElEQVRIS2NkoDFgJNH8/1D1ROsjWiEDA8P/C9ujweYbeC4FUUTpJUoR1NX/z2+DWGDoNVQt2LIkD+wDn5hJtAkimseBR7oU2Ac7Zj6jug/+e6ZLo6To7TOfEmUJMakIw3CYTcRYMuAW4HQ9sb4g5AMUC/4zQEoKRqRMTCiYiLYAZHiQrhvYgnWXd8EtoYoFMMM9bZTBFmw/chduCcUWgNI+yOVetsoM/6FlKSMjA8O2wxBLCOUJgkE0a0oC2HBD41qG82dbGP7//89gYVPLcPJoM9iStJwFePMDURaAgoaRkZHB0LiGgY2DgeHkUYhFoKCiigU09QF6HIDCHwSoFgegMgg5icIyGCypUpyKEIUcrDqGWQHxCqUWgMxANxmlVCVUNxNKReiGkcwHAB32mBlT632HAAAAAElFTkSuQmCC\"}]}"]}}
\ No newline at end of file
diff --git a/src/img/wines/wine-green.png b/src/img/wines/wine-green.png
new file mode 100644
index 000000000..628b43be8
Binary files /dev/null and b/src/img/wines/wine-green.png differ
diff --git a/src/img/wines/wine-purple.piskel b/src/img/wines/wine-purple.piskel
new file mode 100644
index 000000000..25c561f87
--- /dev/null
+++ b/src/img/wines/wine-purple.piskel
@@ -0,0 +1 @@
+{"modelVersion":2,"piskel":{"name":"wine-purple","description":"","fps":12,"height":24,"width":24,"layers":["{\"name\":\"Layer 1\",\"opacity\":1,\"frameCount\":1,\"chunks\":[{\"layout\":[[0]],\"base64PNG\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAABWUlEQVRIS2NkoDFgJNH8/1D1ROsjWiEDA8P/bB5+sPlTv3wEUUTpJUoR1NXDwAIlCVmwZ+69eEybIIrSdwZbsOzi3qFnwX+Y62HJmlhfEJOKMAwnxZKBt0BAUPC/l6whw39YHgblMCZGBpDAskv7CEY2QR/w8PD8X7ZsGcN/mA2MjAyf/SIZ+DYtZ4iKimL48uULXjMIWsArKPh/+vvfDLybloFd/dk/mkF271qGx85BDJmCbAyf37+nzAJuHp7/2zeuZXjkHMQgu3cd2GDejcsYBHk4GDz9gxm+UsUHn/4zMPByMDB8+Ay25MOXHwwMjIwM0dQIIlApemjvToZH7uEMvOsWgVPo5+A4BgY2FoZMtr+UBxHIgkhdJ3DK+f8PkpTAqYjIIoNgJIMsQM/FQyuj0cUH9or6DNJ8IijV99NPbxgO3r9IeU6GVZc4GgcE45CgAhJbHRjKAc39ixlOVxPLAAAAAElFTkSuQmCC\"}]}"]}}
\ No newline at end of file
diff --git a/src/img/wines/wine-purple.png b/src/img/wines/wine-purple.png
new file mode 100644
index 000000000..7925d8f68
Binary files /dev/null and b/src/img/wines/wine-purple.png differ
diff --git a/src/index.js b/src/index.js
index a969917e9..856400fa0 100644
--- a/src/index.js
+++ b/src/index.js
@@ -2,6 +2,21 @@
* @namespace farmhand
*/
+import './polyfills'
+import React from 'react'
+import ReactDOM from 'react-dom'
+import { HashRouter as Router, Route } from 'react-router-dom'
+
+import * as serviceWorkerRegistration from './serviceWorkerRegistration'
+import './index.sass'
+import Farmhand from './components/Farmhand'
+import { features } from './config'
+import 'typeface-francois-one'
+import 'typeface-public-sans'
+
+// eslint-disable-next-line no-unused-vars
+import { cropFamily, grapeVariety } from './enums'
+
/**
* @typedef {import("./components/Farmhand/Farmhand").farmhand.state} farmhand.state
*/
@@ -39,9 +54,24 @@
*/
/**
- * @typedef farmhand.cropVariety
- * @property {string} imageId
- * @extends farmhand.item
+ * @typedef {farmhand.item & {
+ * imageId: string,
+ * cropFamily: cropFamily,
+ * variety: string
+ * }} farmhand.cropVariety
+
+/**
+ * @typedef {farmhand.cropVariety & {
+ * cropFamily: 'GRAPE',
+ * variety: grapeVariety,
+ * wineId: string
+ * }} farmhand.grape
+ */
+
+/**
+ * @typedef {farmhand.recipe & {
+ * variety: grapeVariety
+ * }} farmhand.wine
*/
/**
@@ -126,14 +156,11 @@
*/
/**
- * @typedef farmhand.recipe
- * @type {farmhand.item}
- * @property {recipeType} recipeType The type of recipe
- * this is.
- * @property {{string: number}} ingredients An object where each
- * key is the id of a farmhand.item and the value is the quantity of that item.
- * @property {farmhand.recipeCondition} condition This must return `true` for
- * the recipe to be made available to the player.
+ * @typedef {farmhand.item & {
+ * recipeType: recipeType, // The type of recipe of recipe this is.
+ * ingredients: Record, // An object where each key is the id of a farmhand.item and the value is the quantity of that item.
+ * condition: farmhand.recipeCondition, // This must return `true` for the recipe to be made available to the player.
+ * }} farmhand.recipe
* @readonly
*/
@@ -143,8 +170,8 @@
* @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.
+ * sold. This value can go negative to indicate "days since fermented" or "days
+ * open" When negative, the value of the keg is increased.
*/
/**
@@ -245,18 +272,6 @@
* @property {number} price
*/
-import './polyfills'
-import React from 'react'
-import ReactDOM from 'react-dom'
-import { HashRouter as Router, Route } from 'react-router-dom'
-
-import * as serviceWorkerRegistration from './serviceWorkerRegistration'
-import './index.sass'
-import Farmhand from './components/Farmhand'
-import { features } from './config'
-import 'typeface-francois-one'
-import 'typeface-public-sans'
-
const FarmhandRoute = props =>
ReactDOM.render(
diff --git a/src/services/cellar.js b/src/services/cellar.js
new file mode 100644
index 000000000..c58177cc4
--- /dev/null
+++ b/src/services/cellar.js
@@ -0,0 +1,73 @@
+/**
+ * @typedef {import("..").farmhand.item} item
+ * @typedef {import("..").farmhand.keg} keg
+ */
+
+import { v4 as uuid } from 'uuid'
+
+import { fermentableItemsMap, itemsMap } from '../data/maps'
+import { memoize } from '../utils/memoize'
+import { getYeastRequiredForWine } from '../utils/getYeastRequiredForWine'
+
+import { PURCHASEABLE_CELLARS } from '../constants'
+
+import { wineService } from './wine'
+
+export class CellarService {
+ /**
+ * @private
+ */
+ _uuid = uuid
+
+ getItemInstancesInCellar = memoize(
+ /**
+ * @param {keg[]} cellarInventory
+ * @param {item} item
+ */
+ (cellarInventory, item) => {
+ return cellarInventory.filter(keg => keg.itemId === item.id).length
+ },
+ { cacheSize: Object.keys(fermentableItemsMap).length }
+ )
+
+ /**
+ * @param {item} item
+ */
+ generateKeg = item => {
+ /** @type {keg} */
+ const keg = {
+ id: this._uuid(),
+ itemId: item.id,
+ daysUntilMature: item.daysToFerment ?? 0,
+ }
+
+ if (wineService.isWineRecipe(item)) {
+ keg.daysUntilMature = getYeastRequiredForWine(item.variety)
+ }
+
+ return keg
+ }
+
+ /**
+ * @param {keg[]} cellarInventory
+ * @param {number} purchasedCellar
+ */
+ doesCellarSpaceRemain = (cellarInventory, purchasedCellar) => {
+ return (
+ cellarInventory.length <
+ (PURCHASEABLE_CELLARS.get(purchasedCellar)?.space ?? 0)
+ )
+ }
+
+ /**
+ * @param {keg} keg
+ */
+ doesKegSpoil = keg => {
+ const item = itemsMap[keg.itemId]
+ const doesKegSpoil = !wineService.isWineRecipe(item)
+
+ return doesKegSpoil
+ }
+}
+
+export const cellarService = new CellarService()
diff --git a/src/services/cellar.test.js b/src/services/cellar.test.js
new file mode 100644
index 000000000..69df1d053
--- /dev/null
+++ b/src/services/cellar.test.js
@@ -0,0 +1,48 @@
+import { wineChardonnay } from '../data/recipes'
+import { pumpkin } from '../data/crops'
+
+import { getYeastRequiredForWine } from '../utils/getYeastRequiredForWine'
+
+import { cellarService } from './cellar'
+
+const mockUuid = 'abc123'
+
+beforeEach(() => {
+ // @ts-expect-error
+ jest.spyOn(cellarService, '_uuid').mockReturnValue(mockUuid)
+})
+
+describe('CellarService', () => {
+ describe('generateKeg', () => {
+ test('generates a fermented crop keg', () => {
+ const keg = cellarService.generateKeg(pumpkin)
+
+ expect(keg).toEqual({
+ daysUntilMature: pumpkin.daysToFerment,
+ itemId: pumpkin.id,
+ id: mockUuid,
+ })
+ })
+
+ test('generates a wine keg', () => {
+ const keg = cellarService.generateKeg(wineChardonnay)
+
+ expect(keg).toEqual({
+ daysUntilMature: getYeastRequiredForWine(wineChardonnay.variety),
+ itemId: wineChardonnay.id,
+ id: mockUuid,
+ })
+ })
+ })
+
+ describe('doesKegSpoil', () => {
+ test.each([
+ { keg: cellarService.generateKeg(pumpkin), expected: true },
+ { keg: cellarService.generateKeg(wineChardonnay), expected: false },
+ ])('$keg.itemId spoils -> $expected', ({ keg, expected }) => {
+ const result = cellarService.doesKegSpoil(keg)
+
+ expect(result).toEqual(expected)
+ })
+ })
+})
diff --git a/src/services/wine.js b/src/services/wine.js
new file mode 100644
index 000000000..a284a417d
--- /dev/null
+++ b/src/services/wine.js
@@ -0,0 +1,71 @@
+/**
+ * @typedef {import('../').farmhand.item} item
+ * @typedef {import('../').farmhand.recipe} recipe
+ * @typedef {import('../').farmhand.wine} wine
+ * @typedef {import('../').farmhand.grape} grape
+ * @typedef {import('../enums').grapeVariety} grapeVarietyEnum
+ * @typedef {import('../').farmhand.keg} keg
+ */
+
+import { GRAPES_REQUIRED_FOR_WINE } from '../constants'
+import { wineVarietyValueMap } from '../data/crops/grape'
+import { itemsMap } from '../data/maps'
+import { recipeType } from '../enums'
+import { getInventoryQuantityMap } from '../utils/getInventoryQuantityMap'
+import { getYeastRequiredForWine } from '../utils/getYeastRequiredForWine'
+
+export class WineService {
+ /**
+ * @private
+ */
+ maturityDayMultiplier = 3
+
+ /**
+ * @param {grapeVarietyEnum} grapeVariety
+ */
+ getDaysToMature = grapeVariety => {
+ return wineVarietyValueMap[grapeVariety] * this.maturityDayMultiplier
+ }
+
+ /**
+ * @param {item} recipe
+ * @returns {recipe is wine}
+ */
+ isWineRecipe = recipe => {
+ return 'recipeType' in recipe && recipe.recipeType === recipeType.WINE
+ }
+
+ /**
+ * @param {Object} props
+ * @param {grape} props.grape
+ * @param {{ id: string, quantity: number }[]} props.inventory
+ * @param {keg[]} props.cellarInventory
+ * @param {number} props.cellarSize
+ */
+ getMaxWineYield = ({ grape, inventory, cellarInventory, cellarSize }) => {
+ const {
+ [grape.id]: grapeQuantityInInventory = 0,
+ [itemsMap.yeast.id]: yeastQuantityInInventory = 0,
+ } = getInventoryQuantityMap(inventory)
+
+ const availableCellarSpace = cellarSize - cellarInventory.length
+
+ const grapeQuantityConstraint = Math.floor(
+ grapeQuantityInInventory / GRAPES_REQUIRED_FOR_WINE
+ )
+
+ const yeastQuantityConstraint = Math.floor(
+ yeastQuantityInInventory / getYeastRequiredForWine(grape.variety)
+ )
+
+ const maxYield = Math.min(
+ availableCellarSpace,
+ grapeQuantityConstraint,
+ yeastQuantityConstraint
+ )
+
+ return maxYield
+ }
+}
+
+export const wineService = new WineService()
diff --git a/src/services/wine.test.js b/src/services/wine.test.js
new file mode 100644
index 000000000..3368b1ca8
--- /dev/null
+++ b/src/services/wine.test.js
@@ -0,0 +1,140 @@
+import { GRAPES_REQUIRED_FOR_WINE } from '../constants'
+import { grapeChardonnay, grapeNebbiolo } from '../data/crops'
+import { yeast } from '../data/recipes'
+import { getKegStub } from '../test-utils/stubs/getKegStub'
+import { getYeastRequiredForWine } from '../utils/getYeastRequiredForWine'
+
+import { wineService } from './wine'
+
+describe('WineService', () => {
+ describe('getMaxWineYield', () => {
+ test.each([
+ // Chardonnay
+
+ // Happy path
+ {
+ grape: grapeChardonnay,
+ inventory: [
+ { id: grapeChardonnay.id, quantity: GRAPES_REQUIRED_FOR_WINE },
+ {
+ id: yeast.id,
+ quantity: getYeastRequiredForWine(grapeChardonnay.variety),
+ },
+ ],
+ cellarInventory: [],
+ cellarSize: 10,
+ expected: 1,
+ },
+
+ // Contrained by cellar space
+ {
+ grape: grapeChardonnay,
+ inventory: [
+ { id: grapeChardonnay.id, quantity: Number.MAX_SAFE_INTEGER },
+ { id: yeast.id, quantity: Number.MAX_SAFE_INTEGER },
+ ],
+ cellarInventory: [],
+ cellarSize: 10,
+ expected: 10,
+ },
+
+ // Constrained by yeast inventory
+ {
+ grape: grapeChardonnay,
+ inventory: [
+ { id: grapeChardonnay.id, quantity: Number.MAX_SAFE_INTEGER },
+ { id: yeast.id, quantity: 0 },
+ ],
+ cellarInventory: [],
+ cellarSize: 10,
+ expected: 0,
+ },
+
+ // Constrained by grape inventory
+ {
+ grape: grapeChardonnay,
+ inventory: [
+ { id: grapeChardonnay.id, quantity: 0 },
+ { id: yeast.id, quantity: Number.MAX_SAFE_INTEGER },
+ ],
+ cellarInventory: [],
+ cellarSize: 10,
+ expected: 0,
+ },
+
+ // Constrained by cellar size
+ {
+ grape: grapeChardonnay,
+ inventory: [
+ { id: grapeChardonnay.id, quantity: Number.MAX_SAFE_INTEGER },
+ { id: yeast.id, quantity: Number.MAX_SAFE_INTEGER },
+ ],
+ cellarInventory: [],
+ cellarSize: 1,
+ expected: 1,
+ },
+
+ // Constrained by used cellar space
+ {
+ grape: grapeChardonnay,
+ inventory: [
+ { id: grapeChardonnay.id, quantity: GRAPES_REQUIRED_FOR_WINE },
+ { id: yeast.id, quantity: Number.MAX_SAFE_INTEGER },
+ ],
+ cellarInventory: [getKegStub()],
+ cellarSize: 2,
+ expected: 1,
+ },
+
+ // Nebbiolo
+
+ // Happy path
+ {
+ grape: grapeNebbiolo,
+ inventory: [
+ { id: grapeNebbiolo.id, quantity: GRAPES_REQUIRED_FOR_WINE },
+ {
+ id: yeast.id,
+ quantity: getYeastRequiredForWine(grapeNebbiolo.variety),
+ },
+ ],
+ cellarInventory: [],
+ cellarSize: 10,
+ expected: 1,
+ },
+
+ // Constrained by yeast inventory
+ {
+ grape: grapeNebbiolo,
+ inventory: [
+ { id: grapeNebbiolo.id, quantity: Number.MAX_SAFE_INTEGER },
+ { id: yeast.id, quantity: 0 },
+ ],
+ cellarInventory: [],
+ cellarSize: 10,
+ expected: 0,
+ },
+ ])(
+ `
+grape: $grape.variety
+grape in inventory: $inventory.0.quantity
+yeast in inventory: $inventory.1.quantity
+cellarInventory length: $cellarInventory.length
+cellarSize: $cellarSize
+---------------------
+expect: $expected
+
+`,
+ ({ grape, inventory, cellarInventory, cellarSize, expected }) => {
+ const result = wineService.getMaxWineYield({
+ grape,
+ inventory,
+ cellarInventory,
+ cellarSize,
+ })
+
+ expect(result).toEqual(expected)
+ }
+ )
+ })
+})
diff --git a/src/test-utils/stubs/getKegStub.js b/src/test-utils/stubs/getKegStub.js
index a8a60dd0f..4ded04387 100644
--- a/src/test-utils/stubs/getKegStub.js
+++ b/src/test-utils/stubs/getKegStub.js
@@ -6,13 +6,13 @@ import { carrot } from '../../data/crops'
/**
* @param {Partial} overrides
- * @returns keg
+ * @returns {keg}
*/
-export const getKegStub = overrides => {
+export const getKegStub = (overrides = {}) => {
return {
id: uuid(),
itemId: carrot.id,
- daysUntilMature: carrot.daysToFerment,
+ daysUntilMature: carrot.daysToFerment ?? 0,
...overrides,
}
}
diff --git a/src/utils/doesCellarSpaceRemain.js b/src/utils/doesCellarSpaceRemain.js
deleted file mode 100644
index 0f4d33bb1..000000000
--- a/src/utils/doesCellarSpaceRemain.js
+++ /dev/null
@@ -1,14 +0,0 @@
-/** @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/getInventoryQuantityMap.js b/src/utils/getInventoryQuantityMap.js
index 667bf1682..ec0d810dd 100644
--- a/src/utils/getInventoryQuantityMap.js
+++ b/src/utils/getInventoryQuantityMap.js
@@ -3,18 +3,15 @@
*/
import { memoize } from './memoize'
-/**
- * @param {item[]} inventory
- * @returns {Object}
- */
export const getInventoryQuantityMap = memoize(
/**
- * @param {item[]} inventory
- * @returns {Object}
+ * @param {{ id: item['id'], quantity: number }[]} inventory
+ * @returns {Record- }
*/
inventory =>
inventory.reduce((acc, { id, quantity }) => {
acc[id] = quantity
+
return acc
}, {})
)
diff --git a/src/utils/getKegSpoilageRate.js b/src/utils/getKegSpoilageRate.js
index ca8c438c2..d790b55cc 100644
--- a/src/utils/getKegSpoilageRate.js
+++ b/src/utils/getKegSpoilageRate.js
@@ -1,13 +1,14 @@
/** @typedef {import("../index").farmhand.keg} keg */
import { KEG_SPOILAGE_RATE_MULTIPLIER } from '../constants'
+import { cellarService } from '../services/cellar'
/**
* @param {keg} keg
* @returns number
*/
export const getKegSpoilageRate = keg => {
- if (keg.daysUntilMature > 0) {
+ if (!cellarService.doesKegSpoil(keg) || keg.daysUntilMature > 0) {
return 0
}
diff --git a/src/utils/getKegSpoilageRate.test.js b/src/utils/getKegSpoilageRate.test.js
index 6f1d7f13a..629e18ea9 100644
--- a/src/utils/getKegSpoilageRate.test.js
+++ b/src/utils/getKegSpoilageRate.test.js
@@ -1,3 +1,5 @@
+import { carrot } from '../data/crops'
+import { wineChardonnay } from '../data/recipes'
import { getKegStub } from '../test-utils/stubs/getKegStub'
import { getKegSpoilageRate } from './getKegSpoilageRate'
@@ -8,10 +10,28 @@ describe('getKegSpoilageRate', () => {
expect(getKegSpoilageRate(keg)).toEqual(0)
})
- test('handles kegs that are mature', () => {
- const keg = getKegStub({ daysUntilMature: 0 })
- expect(getKegSpoilageRate(keg)).toEqual(0)
- })
+ test.each([
+ { keg: getKegStub({ itemId: carrot.id, daysUntilMature: 0 }), expected: 0 },
+ {
+ keg: getKegStub({ itemId: carrot.id, daysUntilMature: -10 }),
+ expected: 0.01,
+ },
+ {
+ keg: getKegStub({ itemId: wineChardonnay.id, daysUntilMature: 0 }),
+ expected: 0,
+ },
+ {
+ keg: getKegStub({ itemId: wineChardonnay.id, daysUntilMature: -10 }),
+ expected: 0,
+ },
+ ])(
+ 'calculates spoilage rate for (item: $keg.itemId, daysUntilMature: $keg.daysUntilMature) -> $expected',
+ ({ keg, expected }) => {
+ const result = getKegSpoilageRate(keg)
+
+ expect(result).toEqual(expected)
+ }
+ )
test.each([
[-1, 0.001],
diff --git a/src/utils/getKegValue.js b/src/utils/getKegValue.js
index 09b38c93e..82cdf5a09 100644
--- a/src/utils/getKegValue.js
+++ b/src/utils/getKegValue.js
@@ -1,7 +1,12 @@
/** @typedef {import("../index").farmhand.keg} keg */
-import { KEG_INTEREST_RATE } from '../constants'
+import {
+ KEG_INTEREST_RATE,
+ WINE_GROWTH_TIMELINE_CAP,
+ WINE_INTEREST_RATE,
+} from '../constants'
import { itemsMap } from '../data/maps'
+import { wineService } from '../services/wine'
import { getItemBaseValue } from './getItemBaseValue'
@@ -9,11 +14,28 @@ import { getItemBaseValue } from './getItemBaseValue'
* @param {keg} keg
*/
export const getKegValue = keg => {
- const { itemId } = keg
+ const { itemId, daysUntilMature } = keg
const kegItem = itemsMap[itemId]
- const principalValue = (kegItem.tier ?? 1) * getItemBaseValue(itemId)
- // Standard compound interest rate formula:
+ if (daysUntilMature > 0) return 0
+
+ let principalValue = 0
+ let interestRate = 0
+ let exponent = 0
+
+ if (wineService.isWineRecipe(kegItem)) {
+ principalValue = kegItem.value
+ interestRate = WINE_INTEREST_RATE
+ exponent = Math.min(Math.max(-daysUntilMature, 1), WINE_GROWTH_TIMELINE_CAP)
+ } else {
+ principalValue = (kegItem.tier ?? 1) * getItemBaseValue(itemId)
+ interestRate = KEG_INTEREST_RATE
+ exponent = Math.abs(keg.daysUntilMature)
+ }
+
+ // NOTE: Keg values are (loosely) based on the standard compound interest
+ // rate formula:
+ //
// A = P(1 + r/n)^nt
//
// A = final amount
@@ -21,8 +43,7 @@ export const getKegValue = keg => {
// 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)
+ const kegValue = principalValue * (1 + interestRate) ** exponent
return kegValue
}
diff --git a/src/utils/getKegValue.test.js b/src/utils/getKegValue.test.js
new file mode 100644
index 000000000..a7bb68ac5
--- /dev/null
+++ b/src/utils/getKegValue.test.js
@@ -0,0 +1,96 @@
+import { carrot, sunflower } from '../data/crops'
+import { wineChardonnay, wineNebbiolo } from '../data/recipes'
+import { getKegStub } from '../test-utils/stubs/getKegStub'
+
+import { getKegValue } from './getKegValue'
+
+describe('getKegValue', () => {
+ test.each([
+ // Tier 1 crop
+ {
+ keg: getKegStub({ itemId: carrot.id, daysUntilMature: 5 }),
+ expected: 0,
+ },
+ {
+ keg: getKegStub({ itemId: carrot.id, daysUntilMature: 0 }),
+ expected: 25,
+ },
+ {
+ keg: getKegStub({ itemId: carrot.id, daysUntilMature: -5 }),
+ expected: 27.6,
+ },
+ {
+ keg: getKegStub({ itemId: carrot.id, daysUntilMature: -50 }),
+ expected: 67.29,
+ },
+
+ // Higher tier crop
+ {
+ keg: getKegStub({ itemId: sunflower.id, daysUntilMature: 0 }),
+ expected: 708,
+ },
+ {
+ keg: getKegStub({ itemId: sunflower.id, daysUntilMature: -5 }),
+ expected: 781.69,
+ },
+ {
+ keg: getKegStub({ itemId: sunflower.id, daysUntilMature: -50 }),
+ expected: 1905.64,
+ },
+ ])(
+ 'calculates fermented crop value: (itemId: $keg.itemId, daysUntilMature: $keg.daysUntilMature) -> $expected',
+ ({ keg, expected }) => {
+ const rawResult = getKegValue(keg)
+ const result = Number(rawResult.toFixed(2))
+
+ expect(result).toEqual(expected)
+ }
+ )
+
+ test.each([
+ // Lower value wine
+ {
+ keg: getKegStub({ itemId: wineChardonnay.id, daysUntilMature: 5 }),
+ expected: 0,
+ },
+ {
+ keg: getKegStub({ itemId: wineChardonnay.id, daysUntilMature: 0 }),
+ expected: 23836.64,
+ },
+ {
+ keg: getKegStub({ itemId: wineChardonnay.id, daysUntilMature: -5 }),
+ expected: 25299.34,
+ },
+ // NOTE: Wine value maxes out at 100 days
+ {
+ keg: getKegStub({ itemId: wineChardonnay.id, daysUntilMature: -100 }),
+ expected: 104083.82,
+ },
+ {
+ keg: getKegStub({ itemId: wineChardonnay.id, daysUntilMature: -1000 }),
+ expected: 104083.82,
+ },
+
+ // Lower value wine
+ {
+ keg: getKegStub({ itemId: wineNebbiolo.id, daysUntilMature: 0 }),
+ expected: 148729.22,
+ },
+ {
+ keg: getKegStub({ itemId: wineNebbiolo.id, daysUntilMature: -5 }),
+ expected: 157855.77,
+ },
+ {
+ keg: getKegStub({ itemId: wineNebbiolo.id, daysUntilMature: -100 }),
+ expected: 649433.19,
+ },
+ ])(
+ 'calculates wine keg value (itemId: $keg.itemId, daysUntilMature: $keg.daysUntilMature) -> $expected',
+ ({ keg, expected }) => {
+ const rawResult = getKegValue(keg)
+ const result = Number(rawResult.toFixed(2))
+
+ expect(result).toEqual(expected)
+ }
+ )
+})
diff --git a/src/utils/getMaxYieldOfFermentationRecipe.js b/src/utils/getMaxYieldOfFermentationRecipe.js
index 0af88141b..2284517f5 100644
--- a/src/utils/getMaxYieldOfFermentationRecipe.js
+++ b/src/utils/getMaxYieldOfFermentationRecipe.js
@@ -9,7 +9,7 @@ import { getSaltRequirementsForFermentationRecipe } from './getSaltRequirementsF
/**
* @param {item} fermentationRecipe
- * @param {{ id: string, quantity: number }} inventory
+ * @param {{ id: string, quantity: number }[]} inventory
* @param {Array.} cellarInventory
* @param {number} cellarSize
* @returns {number}
diff --git a/src/utils/getWineVarietiesAvailableToMake.js b/src/utils/getWineVarietiesAvailableToMake.js
new file mode 100644
index 000000000..550a4accf
--- /dev/null
+++ b/src/utils/getWineVarietiesAvailableToMake.js
@@ -0,0 +1,42 @@
+/**
+ * @typedef {import('../index').farmhand.state['itemsSold']} itemsSold
+ * @typedef {import('../index').farmhand.item} item
+ * @typedef {import('../index').farmhand.cropVariety} cropVariety
+ * @typedef {import('../index').farmhand.grape} grape
+ * @typedef {import('../enums').grapeVariety} grapeVariety
+ */
+import { isGrape } from '../data/crops/grape'
+import { itemsMap } from '../data/maps'
+
+/**
+ * @param {itemsSold} itemsSold
+ * @returns {grape[]}
+ */
+const getGrapesSold = itemsSold => {
+ const grapesSold = Object.entries(itemsSold).reduce((
+ /** @type {grape[]} */ acc,
+ [itemId, quantity]
+ ) => {
+ const item = itemsMap[itemId]
+
+ if (quantity > 0 && isGrape(item)) {
+ acc.push(item)
+ }
+
+ return acc
+ }, [])
+
+ return grapesSold
+}
+
+/**
+ * @param {itemsSold} itemsSold
+ * @returns {grapeVariety[]}
+ */
+export function getWineVarietiesAvailableToMake(itemsSold) {
+ const grapesSold = getGrapesSold(itemsSold)
+
+ const winesVarietiesAvailableToMake = grapesSold.map(({ variety }) => variety)
+
+ return winesVarietiesAvailableToMake
+}
diff --git a/src/utils/getWineVarietiesAvailableToMake.test.js b/src/utils/getWineVarietiesAvailableToMake.test.js
new file mode 100644
index 000000000..ea304ee79
--- /dev/null
+++ b/src/utils/getWineVarietiesAvailableToMake.test.js
@@ -0,0 +1,22 @@
+import { carrot, grapeChardonnay, grapeTempranillo } from '../data/crops'
+import { grapeVariety } from '../enums'
+
+import { getWineVarietiesAvailableToMake } from './getWineVarietiesAvailableToMake'
+
+describe('getWineVarietiesAvailableToMake', () => {
+ test.each([
+ [{}, []],
+ [
+ {
+ [grapeTempranillo.id]: 1,
+ [carrot.id]: 1,
+ [grapeChardonnay.id]: 10,
+ },
+ [grapeVariety.TEMPRANILLO, grapeVariety.CHARDONNAY],
+ ],
+ ])('calculates wine varieties available', (itemsSold, expected) => {
+ const result = getWineVarietiesAvailableToMake(itemsSold)
+
+ expect(result).toEqual(expected)
+ })
+})
diff --git a/src/utils/getYeastRequiredForWine.js b/src/utils/getYeastRequiredForWine.js
new file mode 100644
index 000000000..af2ea961b
--- /dev/null
+++ b/src/utils/getYeastRequiredForWine.js
@@ -0,0 +1,13 @@
+import { wineVarietyValueMap } from '../data/crops/grape'
+import { YEAST_REQUIREMENT_FOR_WINE_MULTIPLIER } from '../constants'
+// eslint-disable-next-line no-unused-vars
+import { grapeVariety as grapeVarietyEnum } from '../enums'
+
+/**
+ * @param {grapeVarietyEnum} grapeVariety
+ */
+export const getYeastRequiredForWine = grapeVariety => {
+ return (
+ wineVarietyValueMap[grapeVariety] * YEAST_REQUIREMENT_FOR_WINE_MULTIPLIER
+ )
+}
diff --git a/src/utils/index.js b/src/utils/index.js
index 03ea00d27..0680d1c8d 100644
--- a/src/utils/index.js
+++ b/src/utils/index.js
@@ -8,6 +8,7 @@
/** @typedef {import("../index").farmhand.cowBreedingPen} farmhand.cowBreedingPen */
/** @typedef {import("../enums").cropLifeStage} farmhand.cropLifeStage */
/** @typedef {import("../components/Farmhand/Farmhand").farmhand.state} farmhand.state */
+/** @typedef {import("../index").farmhand.levelEntitlements} farmhand.levelEntitlements */
/**
* @module farmhand.utils
@@ -823,11 +824,9 @@ export const findCowById = memoize(
export const experienceNeededForLevel = targetLevel =>
((targetLevel - 1) * 10) ** 2
-/**
- * @param {Object} levelEntitlements
- * @returns {Array.<{ item: farmhand.item }>}
- */
-export const getAvailableShopInventory = memoize(levelEntitlements =>
+export const getAvailableShopInventory = memoize((
+ /** @type {farmhand.levelEntitlements} */ levelEntitlements
+) =>
shopInventory.filter(
({ id }) =>
!(