diff --git a/.env.development.local b/.env.development.local
index ac7457038..96e1e8f02 100644
--- a/.env.development.local
+++ b/.env.development.local
@@ -1,5 +1,6 @@
REACT_APP_API_ROOT=http://localhost:3001/
REACT_APP_ENABLE_KEGS=true
+REACT_APP_ENABLE_FOREST=true
# Silence warnings from dev server
# https://stackoverflow.com/a/70834076
diff --git a/package-lock.json b/package-lock.json
index 75f0c6e7a..077c7a161 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@jeremyckahn/farmhand",
- "version": "1.18.8",
+ "version": "1.18.9",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@jeremyckahn/farmhand",
- "version": "1.18.8",
+ "version": "1.18.9",
"license": "GPL-2.0-or-later",
"dependencies": {
"@emotion/react": "^11.11.1",
diff --git a/package.json b/package.json
index faf0b423e..2df4430a1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@jeremyckahn/farmhand",
- "version": "1.18.8",
+ "version": "1.18.9",
"publishConfig": {
"access": "public"
},
@@ -24,7 +24,7 @@
"postversion": "git push && git push --tags",
"start": "react-scripts --openssl-legacy-provider start",
"start:api": "NODE_OPTIONS=--openssl-legacy-provider vercel dev --listen localhost:3001",
- "start:backend": "docker-compose up",
+ "start:backend": "docker compose up",
"start:tracker": "bittorrent-tracker",
"test": "react-scripts test",
"test:debug": "react-scripts --inspect-brk test --runInBand --no-cache",
diff --git a/src/components/Farmhand/Farmhand.js b/src/components/Farmhand/Farmhand.js
index 72567e4c8..c5a4273d6 100644
--- a/src/components/Farmhand/Farmhand.js
+++ b/src/components/Farmhand/Farmhand.js
@@ -2,7 +2,9 @@
* @typedef {import("../../index").farmhand.item} farmhand.item
* @typedef {import("../../index").farmhand.cow} farmhand.cow
* @typedef {import("../../index").farmhand.cowBreedingPen} farmhand.cowBreedingPen
+ * @typedef {import("../../index").farmhand.forestForageable} farmhand.forestForageable
* @typedef {import("../../index").farmhand.keg} farmhand.keg
+ * @typedef {import("../../index").farmhand.plantedTree} farmhand.plantedTree
* @typedef {import("../../index").farmhand.plotContent} farmhand.plotContent
* @typedef {import("../../index").farmhand.peerMessage} farmhand.peerMessage
* @typedef {import("../../index").farmhand.peerMetadata} farmhand.peerMetadata
@@ -60,6 +62,7 @@ import { levelAchieved } from '../../utils/levelAchieved'
import {
computeMarketPositions,
createNewField,
+ createNewForest,
doesMenuObstructStage,
generateCow,
getAvailableShopInventory,
@@ -116,7 +119,7 @@ import {
SERVER_ERROR,
UPDATE_AVAILABLE,
} from '../../strings'
-import { endpoints, rtcConfig, trackerUrls } from '../../config'
+import { endpoints, features, rtcConfig, trackerUrls } from '../../config'
import { scarecrow } from '../../data/items'
@@ -214,6 +217,7 @@ const applyPriceEvents = (valueAdjustments, priceCrashes, priceSurges) => {
* @property {number} experience
* @property {string} farmName
* @property {(?farmhand.plotContent)[][]} field
+ * @property {(farmhand.plantedTree | farmhand.forestForageable | null)[][]} forest
* @property {farmhand.fieldMode} fieldMode
* @property {Function?} getCowAccept https://github.com/dmotz/trystero#receiver
* @property {Function?} getCowReject https://github.com/dmotz/trystero#receiver
@@ -272,6 +276,7 @@ const applyPriceEvents = (valueAdjustments, priceCrashes, priceSurges) => {
* @property {number} purchasedCowPen
* @property {number} purchasedCellar
* @property {number} purchasedField
+ * @property {number} purchasedForest
* @property {number} purchasedSmelter
* @property {number} profitabilityStreak
* @property {number} record7dayProfitAverage
@@ -287,6 +292,7 @@ const applyPriceEvents = (valueAdjustments, priceCrashes, priceSurges) => {
* @property {boolean} showHomeScreen Option to show the Home Screen
* @property {boolean} showNotifications
* @property {farmhand.stageFocusType} stageFocus
+ * indicating if the stage has been unlocked
* @property {Array.} todaysNotifications
* @property {number} todaysLosses Should always be a negative number.
* @property {Object} todaysPurchases Keys are item names, values are their
@@ -364,13 +370,17 @@ export default class Farmhand extends FarmhandReducers {
}
get viewList() {
- const { CELLAR, COW_PEN, HOME, WORKSHOP } = stageFocusType
+ const { CELLAR, COW_PEN, HOME, WORKSHOP, FOREST } = stageFocusType
const viewList = [...STANDARD_VIEW_LIST]
if (this.state.showHomeScreen) {
viewList.unshift(HOME)
}
+ if (this.isForestUnlocked && features.FOREST) {
+ viewList.push(FOREST)
+ }
+
if (this.state.purchasedCowPen) {
viewList.push(COW_PEN)
}
@@ -409,6 +419,10 @@ export default class Farmhand extends FarmhandReducers {
return isOnline && room !== DEFAULT_ROOM
}
+ get isForestUnlocked() {
+ return this.levelEntitlements.stageFocusType[stageFocusType.FOREST]
+ }
+
/**
* @returns {farmhand.state}
*/
@@ -437,6 +451,7 @@ export default class Farmhand extends FarmhandReducers {
farmName: 'Unnamed',
field: createNewField(),
fieldMode: OBSERVE,
+ forest: createNewForest(),
getCowAccept: noop,
getCowReject: noop,
getCowTradeRequest: noop,
@@ -490,6 +505,7 @@ export default class Farmhand extends FarmhandReducers {
purchasedCowPen: 0,
purchasedCellar: 0,
purchasedField: 0,
+ purchasedForest: 0,
purchasedSmelter: 0,
sendCowTradeRequest: noop,
showHomeScreen: true,
diff --git a/src/components/Farmhand/FarmhandReducers.js b/src/components/Farmhand/FarmhandReducers.js
index c4f178510..de7346713 100644
--- a/src/components/Farmhand/FarmhandReducers.js
+++ b/src/components/Farmhand/FarmhandReducers.js
@@ -118,6 +118,10 @@ export class FarmhandReducers extends Component {
throw new Error('Unimplemented')
}
/** @type BoundReducer */
+ purchaseForest() {
+ throw new Error('Unimplemented')
+ }
+ /** @type BoundReducer */
purchaseItem() {
throw new Error('Unimplemented')
}
diff --git a/src/components/Forest/Forest.js b/src/components/Forest/Forest.js
new file mode 100644
index 000000000..176b34afe
--- /dev/null
+++ b/src/components/Forest/Forest.js
@@ -0,0 +1,5 @@
+import React from 'react'
+
+export const Forest = () => {
+ return 'welcome to da forest'
+}
diff --git a/src/components/Forest/index.js b/src/components/Forest/index.js
new file mode 100644
index 000000000..f8db3e650
--- /dev/null
+++ b/src/components/Forest/index.js
@@ -0,0 +1 @@
+export { Forest } from './Forest'
diff --git a/src/components/Item/Item.js b/src/components/Item/Item.js
index a1335d2b5..675534bea 100644
--- a/src/components/Item/Item.js
+++ b/src/components/Item/Item.js
@@ -213,6 +213,19 @@ export const Item = ({
)}
)}
+ {isPurchaseView && (
+
+ Total:{' '}
+ {purchaseQuantity ? (
+
+ ) : null}
+
+ )}
{isSellView && (
)}
+ {isSellView && (
+
+ Total:{' '}
+ {sellQuantity ? (
+
+ ) : null}
+
+ )}
{showQuantity && (
In inventory:{' '}
diff --git a/src/components/Item/Item.test.js b/src/components/Item/Item.test.js
index e62bc39b9..ec134f5a5 100644
--- a/src/components/Item/Item.test.js
+++ b/src/components/Item/Item.test.js
@@ -1,6 +1,6 @@
import React from 'react'
-import CardHeader from '@mui/material/CardHeader'
-import { shallow } from 'enzyme'
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import { testItem } from '../../test-utils'
@@ -10,95 +10,123 @@ import { Item } from './Item'
jest.mock('../../data/maps')
-let component
-
-beforeEach(() => {
- component = shallow(
-
- )
-})
-
-describe('static UI', () => {
- beforeEach(() => {
- component.setProps({ item: testItem({ name: 'an-item' }) })
- })
-
- test('renders the name', () => {
- expect(component.find(CardHeader).props().title).toEqual('an-item')
- })
-})
-
-describe('conditional UI', () => {
- describe('class names', () => {
- beforeEach(() => {
- component.setProps({ isSelected: true })
+describe('Item', () => {
+ const baseProps = {
+ completedAchievements: {},
+ historicalValueAdjustments: [],
+ inventory: [],
+ inventoryLimit: INFINITE_STORAGE_LIMIT,
+ item: testItem(),
+ money: 0,
+ playerInventoryQuantities: {},
+ valueAdjustments: {},
+ adjustedValue: 0,
+ previousDayAdjustedValue: 0,
+ }
+
+ describe('static UI', () => {
+ test('renders the name', () => {
+ const itemName = 'Cool Item'
+ render( )
+ expect(screen.getByText(itemName)).toBeInTheDocument()
})
+ })
- test('supports is-selected', () => {
- expect(component.hasClass('is-selected')).toBeTruthy()
+ describe('conditional UI', () => {
+ describe('class names', () => {
+ test('supports is-selected', () => {
+ const { container } = render(
+
+ )
+ expect(container.firstChild).toHaveClass('is-selected')
+ })
})
- })
- describe('isPurchaseView', () => {
- beforeEach(() => {
- component.setProps({
+ describe('isPurchaseView', () => {
+ const props = {
+ ...baseProps,
isPurchaseView: true,
- item: testItem({ name: 'an-item' }),
- adjustedValue: 10,
+ adjustedValue: 10.42,
+ }
+
+ describe('user has enough money', () => {
+ beforeEach(() => {
+ render( )
+ })
+
+ test('enables purchase buttons', () => {
+ expect(screen.getByRole('button', { name: 'Buy' })).not.toBeDisabled()
+ })
})
- })
- describe('user has enough money', () => {
- beforeEach(() => {
- component.setProps({
- money: 20,
+ describe('user does not have enough money', () => {
+ beforeEach(() => {
+ render( )
+ })
+
+ test('disables purchase buttons', () => {
+ expect(screen.getByRole('button', { name: 'Buy' })).toBeDisabled()
})
})
- test('enables purchase buttons', () => {
- expect(component.find('.purchase').props().disabled).toEqual(false)
+ describe('prices', () => {
+ beforeEach(() => {
+ render( )
+ })
+
+ test('displays item price', () => {
+ const buyPrice = screen.getByText('Price:')
+ expect(within(buyPrice).getByText('$10.42')).toBeInTheDocument()
+ })
+
+ test('displays total price', async () => {
+ const increment = screen.getByRole('button', { name: 'Increment' })
+ userEvent.click(increment)
+ const total = screen.getByText('Total:')
+ await waitFor(() =>
+ expect(within(total).getByText('$20.84')).toBeInTheDocument()
+ )
+ })
})
})
- describe('user does not have enough money', () => {
+ describe('isSellView', () => {
beforeEach(() => {
- component.setProps({
- money: 5,
- })
+ const id = 'an-item'
+ render(
+
+ )
})
- test('disables purchase buttons', () => {
- expect(component.find('.purchase').props().disabled).toEqual(true)
+ test('renders sell buttons', () => {
+ expect(screen.getByRole('button', { name: 'Sell' })).toBeInTheDocument()
})
- })
- })
- describe('isSellView', () => {
- beforeEach(() => {
- component.setProps({
- isSellView: true,
- item: testItem({ id: 'an-item' }),
- playerInventoryQuantities: {
- 'an-item': 1,
- },
- })
- })
+ describe('prices', () => {
+ test('displays item price', () => {
+ const sellPrice = screen.getByText('Sell price:')
+ expect(within(sellPrice).getByText('$10.42')).toBeInTheDocument()
+ })
- test('renders sell buttons', () => {
- expect(component.find('.sell')).toHaveLength(1)
+ test('displays total price', async () => {
+ const increment = screen.getByRole('button', { name: 'Increment' })
+ userEvent.click(increment)
+ userEvent.click(increment)
+ userEvent.click(increment)
+ const total = screen.getByText('Total:')
+ await waitFor(() =>
+ expect(within(total).getByText('$41.68')).toBeInTheDocument()
+ )
+ })
+ })
})
})
})
diff --git a/src/components/Shop/Shop.js b/src/components/Shop/Shop.js
index d467d70a2..08099ead0 100644
--- a/src/components/Shop/Shop.js
+++ b/src/components/Shop/Shop.js
@@ -19,7 +19,7 @@ import {
} from '../../utils'
import { memoize } from '../../utils/memoize'
import { items } from '../../img'
-import { itemType, toolType } from '../../enums'
+import { itemType, stageFocusType, toolType } from '../../enums'
import {
INFINITE_STORAGE_LIMIT,
PURCHASEABLE_CELLARS,
@@ -27,6 +27,7 @@ import {
PURCHASEABLE_COMPOSTERS,
PURCHASEABLE_COW_PENS,
PURCHASEABLE_FIELD_SIZES,
+ PURCHASABLE_FOREST_SIZES,
PURCHASEABLE_SMELTERS,
STORAGE_EXPANSION_AMOUNT,
} from '../../constants'
@@ -60,15 +61,18 @@ export const Shop = ({
handleCowPenPurchase,
handleCellarPurchase,
handleFieldPurchase,
+ handleForestPurchase,
handleSmelterPurchase,
handleStorageExpansionPurchase,
inventoryLimit,
+ levelEntitlements,
money,
purchasedCombine,
purchasedComposter,
purchasedCowPen,
purchasedCellar,
purchasedField,
+ purchasedForest,
purchasedSmelter,
shopInventory,
toolLevels,
@@ -79,6 +83,9 @@ export const Shop = ({
const { seeds, fieldTools } = categorizeShopInventory(shopInventory)
+ const isForestUnlocked =
+ levelEntitlements.stageFocusType[stageFocusType.FOREST]
+
return (
) : null}
+ {features.FOREST && isForestUnlocked ? (
+
+
+ `${dollarString(price)}: ${columns} x ${rows}`,
+ tiers: PURCHASABLE_FOREST_SIZES,
+ title: 'Expand Forest',
+ }}
+ />
+
+ ) : null}
{
- component = shallow(
-
+ const gameState = {
+ inventoryLimit: INFINITE_STORAGE_LIMIT,
+ levelEntitlements: {
+ stageFocusType: {},
+ },
+ money: 0,
+ purchasedCombine: 0,
+ purchasedCowPen: 0,
+ purchasedCellar: 0,
+ purchasedSmelter: 0,
+ purchasedField: 0,
+ shopInventory: [],
+ toolLevels: {},
+ valueAdjustments: {},
+ }
+
+ const handlers = {
+ handleCombinePurchase: noop,
+ handleCowPenPurchase: noop,
+ handleCellarPurchase: noop,
+ handleFieldPurchase: noop,
+ handleStorageExpansionPurchase: noop,
+ }
+
+ render(
+
+
+
)
})
-test('renders shop inventory', () => {
- expect(component.find(Inventory)).toHaveLength(2)
+describe('', () => {
+ test.each(['Seeds', 'Supplies', 'Upgrades'])(
+ 'the %s tab exists',
+ tabLabel => {
+ expect(screen.getByText(tabLabel)).toBeInTheDocument()
+ }
+ )
})
diff --git a/src/components/Stage/Stage.js b/src/components/Stage/Stage.js
index eb0c776b8..88ef48da5 100644
--- a/src/components/Stage/Stage.js
+++ b/src/components/Stage/Stage.js
@@ -4,6 +4,7 @@ import { array, arrayOf, string } from 'prop-types'
import FarmhandContext from '../Farmhand/Farmhand.context'
import Field from '../Field'
+import { Forest } from '../Forest'
import Home from '../Home'
import CowPen from '../CowPen'
import Shop from '../Shop'
@@ -52,6 +53,7 @@ export const Stage = ({ field, stageFocus, viewTitle }) => {
}}
/>
)}
+ {stageFocus === stageFocusType.FOREST && }
{stageFocus === stageFocusType.SHOP && }
{stageFocus === stageFocusType.COW_PEN && }
{stageFocus === stageFocusType.WORKSHOP && }
diff --git a/src/constants.js b/src/constants.js
index 75700c39a..e4a081b74 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -49,6 +49,20 @@ export const PURCHASEABLE_FIELD_SIZES = freeze(
])
)
+export const INITIAL_FOREST_WIDTH = 4
+export const INITIAL_FOREST_HEIGHT = 1
+
+/**
+ * @type Map
+ */
+export const PURCHASABLE_FOREST_SIZES = freeze(
+ new Map([
+ [1, { columns: 4, rows: 2, price: 100_000 }],
+ [2, { columns: 4, rows: 3, price: 200_000 }],
+ [3, { columns: 4, rows: 4, price: 300_000 }],
+ ])
+)
+
export const LARGEST_PURCHASABLE_FIELD_SIZE = /** @type {farmhand.purchaseableFieldSize} */ (PURCHASEABLE_FIELD_SIZES.get(
PURCHASEABLE_FIELD_SIZES.size
))
@@ -130,6 +144,7 @@ export const PRICE_EVENT_STANDARD_DURATION_DECREASE = 1
export const STAGE_TITLE_MAP = {
[stageFocusType.HOME]: 'Home',
[stageFocusType.FIELD]: 'Field',
+ [stageFocusType.FOREST]: 'Forest',
[stageFocusType.SHOP]: 'Shop',
[stageFocusType.COW_PEN]: 'Cows',
[stageFocusType.WORKSHOP]: 'Workshop',
@@ -157,6 +172,7 @@ export const PERSISTED_STATE_KEYS = [
'experience',
'farmName',
'field',
+ 'forest',
'historicalDailyLosses',
'historicalDailyRevenue',
'historicalValueAdjustments',
@@ -181,6 +197,7 @@ export const PERSISTED_STATE_KEYS = [
'purchasedCowPen',
'purchasedCellar',
'purchasedField',
+ 'purchasedForest',
'purchasedSmelter',
'record7dayProfitAverage',
'recordProfitabilityStreak',
@@ -280,6 +297,7 @@ export const EXPERIENCE_VALUES = {
COW_TRADED: 1,
FERMENTATION_RECIPE_MADE: 1,
FIELD_EXPANDED: 5,
+ FOREST_EXPANDED: 10,
FORGE_RECIPE_MADE: 3,
ITEM_SOLD: 1,
KEG_SOLD: 2,
diff --git a/src/data/levels.js b/src/data/levels.js
index 30708053d..7d70cd10a 100644
--- a/src/data/levels.js
+++ b/src/data/levels.js
@@ -1,4 +1,4 @@
-import { toolType } from '../enums'
+import { stageFocusType, toolType } from '../enums'
import * as items from './items'
import * as recipes from './recipes'
@@ -45,6 +45,10 @@ levels[14] = {
unlocksShopItem: items.potatoSeed.id,
}
+levels[15] = {
+ unlocksStageFocusType: stageFocusType.FOREST,
+}
+
levels[16] = {
unlocksShopItem: items.onionSeed.id,
}
diff --git a/src/enums.js b/src/enums.js
index 9ab5257c3..38484bbd8 100644
--- a/src/enums.js
+++ b/src/enums.js
@@ -72,6 +72,7 @@ export const stageFocusType = enumify([
'NONE', // Used for testing
'HOME',
'FIELD',
+ 'FOREST',
'SHOP',
'COW_PEN',
'INVENTORY',
diff --git a/src/game-logic/reducers/index.js b/src/game-logic/reducers/index.js
index ae38907ad..99f14ed58 100644
--- a/src/game-logic/reducers/index.js
+++ b/src/game-logic/reducers/index.js
@@ -48,6 +48,7 @@ export * from './purchaseCow'
export * from './purchaseCowPen'
export * from './purchaseCellar'
export * from './purchaseField'
+export * from './purchaseForest'
export * from './purchaseItem'
export * from './purchaseSmelter'
export * from './purchaseStorageExpansion'
@@ -64,6 +65,7 @@ export * from './setScarecrow'
export * from './setSprinkler'
export * from './showNotification'
export * from './spawnWeeds'
+export * from './unlockTool'
export * from './updateAchievements'
export * from './updateFinancialRecords'
export * from './updateInventoryRecordsForNextDay'
diff --git a/src/game-logic/reducers/processLevelUp.js b/src/game-logic/reducers/processLevelUp.js
index 7793bc57b..7592028ae 100644
--- a/src/game-logic/reducers/processLevelUp.js
+++ b/src/game-logic/reducers/processLevelUp.js
@@ -3,7 +3,6 @@ import { levelAchieved } from '../../utils/levelAchieved'
import {
getRandomLevelUpReward,
getRandomLevelUpRewardQuantity,
- unlockTool,
} from '../../utils'
import { getLevelEntitlements } from '../../utils/getLevelEntitlements'
import { SPRINKLER_ITEM_ID } from '../../constants'
@@ -11,6 +10,7 @@ import { LEVEL_GAINED_NOTIFICATION } from '../../templates'
import { addItemToInventory } from './addItemToInventory'
import { showNotification } from './showNotification'
+import { unlockTool } from './unlockTool'
/**
* @param {farmhand.state} state
@@ -35,8 +35,8 @@ export const processLevelUp = (state, oldLevel) => {
getRandomLevelUpRewardQuantity(i),
true
)
- } else if (levelObject && levelObject.unlocksTool) {
- state.toolLevels = unlockTool(state.toolLevels, levelObject.unlocksTool)
+ } else if (levelObject?.unlocksTool) {
+ state = unlockTool(state, levelObject.unlocksTool)
}
// This handles an edge case where the player levels up to level that
// unlocks greater sprinkler range, but the sprinkler item is already
diff --git a/src/game-logic/reducers/purchaseForest.js b/src/game-logic/reducers/purchaseForest.js
new file mode 100644
index 000000000..eb8bda997
--- /dev/null
+++ b/src/game-logic/reducers/purchaseForest.js
@@ -0,0 +1,45 @@
+import { moneyTotal, nullArray } from '../../utils'
+import { EXPERIENCE_VALUES, PURCHASABLE_FOREST_SIZES } from '../../constants'
+import { FOREST_EXPANDED } from '../../templates'
+import { FOREST_AVAILABLE_NOTIFICATION } from '../../strings'
+
+import { addExperience } from './addExperience'
+import { showNotification } from './showNotification'
+
+/**
+ * @param {farmhand.state} state
+ * @param {number} forestId
+ * @returns {farmhand.state}
+ */
+export const purchaseForest = (state, forestId) => {
+ const { forest, money, purchasedForest } = state
+ if (purchasedForest >= forestId) {
+ return state
+ }
+
+ state = addExperience(state, EXPERIENCE_VALUES.FOREST_EXPANDED)
+
+ const { columns, price, rows } = PURCHASABLE_FOREST_SIZES.get(forestId)
+
+ /*
+ * FIXME: using FOREST_AVAILABLE_NOTIFICATION here is temporary, this code path will
+ * ultimately just be for expansion and availability will happen elsewhere, such as
+ * through leveling up to a certain level
+ */
+ const notificationText =
+ forestId === 1
+ ? FOREST_AVAILABLE_NOTIFICATION
+ : FOREST_EXPANDED`${rows * columns}`
+ state = showNotification(state, notificationText, 'success')
+
+ return {
+ ...state,
+ purchasedForest: forestId,
+ forest: nullArray(rows).map((_, row) =>
+ nullArray(columns).map(
+ (_, column) => (forest[row] && forest[row][column]) || null
+ )
+ ),
+ money: moneyTotal(money, -price),
+ }
+}
diff --git a/src/game-logic/reducers/purchaseForest.test.js b/src/game-logic/reducers/purchaseForest.test.js
new file mode 100644
index 000000000..0905246ae
--- /dev/null
+++ b/src/game-logic/reducers/purchaseForest.test.js
@@ -0,0 +1,82 @@
+import { EXPERIENCE_VALUES, PURCHASABLE_FOREST_SIZES } from '../../constants'
+import { FOREST_AVAILABLE_NOTIFICATION } from '../../strings'
+
+import { purchaseForest } from './purchaseForest'
+
+const tree = () => {
+ return {
+ daysOld: 0,
+ itemId: 'test-tree',
+ }
+}
+
+describe('purchaseForest', () => {
+ test('updates purchasedForest', () => {
+ const { purchasedForest } = purchaseForest({ purchasedForest: 0 }, 0)
+ expect(purchasedForest).toEqual(0)
+ })
+
+ test('prevents repurchasing options', () => {
+ const { purchasedForest } = purchaseForest({ purchasedForest: 2 }, 1)
+ expect(purchasedForest).toEqual(2)
+ })
+
+ test('deducts money', () => {
+ const { money } = purchaseForest(
+ { todaysNotifications: [], money: 101_000, forest: [[]] },
+ 1
+ )
+ expect(money).toEqual(1_000)
+ })
+
+ test('adds experience', () => {
+ const { experience } = purchaseForest(
+ { experience: 0, todaysNotifications: [], forest: [[]] },
+ 1
+ )
+
+ expect(experience).toEqual(EXPERIENCE_VALUES.FOREST_EXPANDED)
+ })
+
+ test('shows notification', () => {
+ const { todaysNotifications } = purchaseForest(
+ { todaysNotifications: [], forest: [[]] },
+ 1
+ )
+
+ expect(todaysNotifications[0].message).toEqual(
+ FOREST_AVAILABLE_NOTIFICATION
+ )
+ })
+
+ describe('forest expansion', () => {
+ test('forest expands without destroying existing data', () => {
+ const expectedForest = []
+ const forestSize = PURCHASABLE_FOREST_SIZES.get(1)
+
+ for (let y = 0; y < forestSize.rows; y++) {
+ const row = []
+ for (let x = 0; x < forestSize.columns; x++) {
+ row.push(null)
+ }
+ expectedForest.push(row)
+ }
+
+ const { forest } = purchaseForest(
+ {
+ todaysNotifications: [],
+ forest: [
+ [tree(), null],
+ [null, tree()],
+ ],
+ },
+ 1
+ )
+
+ expectedForest[0][0] = tree()
+ expectedForest[1][1] = tree()
+
+ expect(forest).toEqual(expectedForest)
+ })
+ })
+})
diff --git a/src/game-logic/reducers/sellItem.js b/src/game-logic/reducers/sellItem.js
index 6067f1c97..43266fe8b 100644
--- a/src/game-logic/reducers/sellItem.js
+++ b/src/game-logic/reducers/sellItem.js
@@ -1,6 +1,5 @@
import { itemsMap } from '../../data/maps'
import { isItemAFarmProduct } from '../../utils/isItemAFarmProduct'
-import { levelAchieved } from '../../utils/levelAchieved'
import {
castToMoney,
getAdjustedItemValue,
@@ -13,7 +12,6 @@ import { LOAN_GARNISHMENT_RATE, EXPERIENCE_VALUES } from '../../constants'
import { SOLD_ITEM_PEER_NOTIFICATION } from '../../templates'
import { decrementItemFromInventory } from './decrementItemFromInventory'
-import { processLevelUp } from './processLevelUp'
import { addExperience } from './addExperience'
import { addRevenue } from './addRevenue'
import { updateLearnedRecipes } from './updateLearnedRecipes'
@@ -35,12 +33,10 @@ export const sellItem = (state, { id }, howMany = 1) => {
const item = itemsMap[id]
const {
completedAchievements,
- experience,
itemsSold,
money: initialMoney,
valueAdjustments,
} = state
- const oldLevel = levelAchieved(experience)
let { loanBalance } = state
const adjustedItemValue = isItemSoldInShop(item)
@@ -93,7 +89,6 @@ export const sellItem = (state, { id }, howMany = 1) => {
itemsSold: newItemsSold,
}
- state = processLevelUp(state, oldLevel)
state = decrementItemFromInventory(state, id, howMany)
state = prependPendingPeerMessage(
diff --git a/src/game-logic/reducers/unlockTool.js b/src/game-logic/reducers/unlockTool.js
new file mode 100644
index 000000000..933504d20
--- /dev/null
+++ b/src/game-logic/reducers/unlockTool.js
@@ -0,0 +1,26 @@
+/** @typedef {import("../../enums").toolLevel} farmhand.toolLevel */
+/** @typedef {import("../../enums").toolType} farmhand.toolType */
+/** @typedef {import("../../components/Farmhand/Farmhand").farmhand.state} farmhand.state */
+
+import { toolLevel } from '../../enums'
+
+/**
+ * @param {Object.} toolLevels
+ * @param {farmhand.toolType} toolType
+ * @returns {farmhand.state}
+ */
+export const unlockTool = (state, toolType) => {
+ const { toolLevels } = state
+
+ if (toolLevels[toolType] === toolLevel.UNAVAILABLE) {
+ return {
+ ...state,
+ toolLevels: {
+ ...toolLevels,
+ [toolType]: toolLevel.DEFAULT,
+ },
+ }
+ }
+
+ return state
+}
diff --git a/src/game-logic/reducers/unlockTool.test.js b/src/game-logic/reducers/unlockTool.test.js
new file mode 100644
index 000000000..31a1ce351
--- /dev/null
+++ b/src/game-logic/reducers/unlockTool.test.js
@@ -0,0 +1,37 @@
+import { toolLevel, toolType } from '../../enums'
+
+import { unlockTool } from './unlockTool'
+
+describe('unlockTool', () => {
+ it('unlocks the specified tool', () => {
+ const state = {
+ toolLevels: {
+ [toolType.SHOVEL]: toolLevel.UNAVAILABLE,
+ },
+ }
+
+ const { toolLevels } = unlockTool(state, toolType.SHOVEL)
+
+ expect(toolLevels[toolType.SHOVEL]).toEqual(toolLevel.DEFAULT)
+ })
+
+ it('does not alter the rest of the tools', () => {
+ const state = {
+ toolLevels: {
+ [toolType.SHOVEL]: toolLevel.UNAVAILABLE,
+ [toolType.HOE]: toolLevel.DEFAULT,
+ [toolType.SCYTHE]: toolLevel.GOLD,
+ },
+ }
+
+ const { toolLevels } = unlockTool(state, toolType.SHOVEL)
+
+ expect(toolLevels).toMatchInlineSnapshot(`
+ Object {
+ "HOE": "DEFAULT",
+ "SCYTHE": "GOLD",
+ "SHOVEL": "DEFAULT",
+ }
+ `)
+ })
+})
diff --git a/src/handlers/ui-events.js b/src/handlers/ui-events.js
index 41812f659..74ad77b69 100644
--- a/src/handlers/ui-events.js
+++ b/src/handlers/ui-events.js
@@ -267,6 +267,13 @@ export default {
this.purchaseField(fieldId)
},
+ /**
+ * @param {number} forestId
+ */
+ handleForestPurchase(forestId) {
+ this.purchaseForest(forestId)
+ },
+
/**
* @param {number} combineId
*/
diff --git a/src/index.js b/src/index.js
index 28ae1aaa2..a969917e9 100644
--- a/src/index.js
+++ b/src/index.js
@@ -64,6 +64,20 @@
* @typedef {farmhand.plotContent & farmhand.cropType} farmhand.crop
*/
+/**
+ * Represents a tree
+ * @typedef farmhand.plantedTree
+ * @property {number} daysOld
+ * @property {string} itemId
+ */
+
+/**
+ * Represents a forageable item that grows in the forest
+ * @typedef farmhand.forestForageable
+ * @property {number} daysOld
+ * @property {'mushroom' | 'acorn'} forageableId
+ */
+
/**
* Represents a shoveled plot
* @typedef farmhand.shoveledPlot
diff --git a/src/strings.js b/src/strings.js
index 9ea77d4b1..06598fb5b 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -79,6 +79,8 @@ export const FORGE_AVAILABLE_NOTIFICATION =
export const RECYCLING_AVAILABLE_NOTIFICATION =
'**Recycling** is now available in the Workshop!'
+export const FOREST_AVAILABLE_NOTIFICATION = 'The **Forest** is now available!'
+
export const COW_COLOR_NAMES = {
[cowColors.BLUE]: 'Blue',
[cowColors.BROWN]: 'Brown',
@@ -89,3 +91,6 @@ export const COW_COLOR_NAMES = {
[cowColors.WHITE]: 'White',
[cowColors.YELLOW]: 'Yellow',
}
+
+export const SHOVEL_UNLOCKED =
+ "You've unlocked a new tool for the field, The **Shovel**!"
diff --git a/src/templates.js b/src/templates.js
index 9e8ea5f7d..c18fda32a 100644
--- a/src/templates.js
+++ b/src/templates.js
@@ -10,9 +10,11 @@
* @ignore
*/
+import { FOREST_AVAILABLE_NOTIFICATION, SHOVEL_UNLOCKED } from './strings'
import { itemUnlockLevels, levels } from './data/levels'
import { itemsMap } from './data/maps'
import { moneyString } from './utils/moneyString'
+import { stageFocusType, toolType } from './enums'
import {
getCowDisplayName,
getPlayerName,
@@ -205,9 +207,13 @@ export const LEVEL_GAINED_NOTIFICATION = (_, newLevel, randomCropSeed) => {
}** as a reward!`
)
} else if (levelObject && levelObject.unlocksTool) {
- // todo: there is only one tool that can be unlocked currently, but this is a bit
- // short-sighted if we ever introduce other tool unlocks
- chunks.push(`You've unlocked a new tool for the field, The **Shovel**!`)
+ if (levelObject.unlocksTool === toolType.SHOVEL) {
+ chunks.push(SHOVEL_UNLOCKED)
+ }
+ } else if (levelObject && levelObject.unlocksStageFocusType) {
+ if (levelObject.unlocksStageFocusType === stageFocusType.FOREST) {
+ chunks.push(FOREST_AVAILABLE_NOTIFICATION)
+ }
}
return chunks.join(' ')
@@ -366,3 +372,11 @@ export const KEG_SPOILED_MESSAGE = (_, keg) =>
*/
export const NEW_COW_OFFERED_FOR_TRADE = (_, peerMetadata) =>
`A new cow is being offered for trade by ${getPlayerName(peerMetadata.id)}!`
+
+/**
+ * @param {TemplatesStringsArray}
+ * @param {number} numTrees
+ * @returns {string}
+ */
+export const FOREST_EXPANDED = (_, numTrees) =>
+ `The Forest has expanded! You can now plant up to ${numTrees} trees.`
diff --git a/src/test-utils/index.js b/src/test-utils/index.js
index d084661f9..e0b02002a 100644
--- a/src/test-utils/index.js
+++ b/src/test-utils/index.js
@@ -28,5 +28,8 @@ export const testItem = (item = {}) => ({
id: '',
name: '',
value: 0,
+ description: '',
+ doesPriceFluctuate: false,
+ isReplantable: false,
...item,
})
diff --git a/src/utils/getLevelEntitlements.js b/src/utils/getLevelEntitlements.js
index 15a49daa8..e998ba5ab 100644
--- a/src/utils/getLevelEntitlements.js
+++ b/src/utils/getLevelEntitlements.js
@@ -20,11 +20,18 @@ export const getLevelEntitlements = memoize(
sprinklerRange: INITIAL_SPRINKLER_RANGE,
items: {},
tools: {},
+ stageFocusType: {},
}
// Assumes that levels is sorted by id.
levels.find(
- ({ unlocksShopItem, unlocksTool, id, increasesSprinklerRange }) => {
+ ({
+ unlocksShopItem,
+ unlocksStageFocusType,
+ unlocksTool,
+ id,
+ increasesSprinklerRange,
+ }) => {
if (increasesSprinklerRange) {
acc.sprinklerRange++
}
@@ -37,6 +44,10 @@ export const getLevelEntitlements = memoize(
acc.tools[unlocksTool] = true
}
+ if (unlocksStageFocusType) {
+ acc.stageFocusType[unlocksStageFocusType] = true
+ }
+
return id === levelNumber
}
)
diff --git a/src/utils/getLevelEntitlements.test.js b/src/utils/getLevelEntitlements.test.js
index 39021fbde..2fb3e2b74 100644
--- a/src/utils/getLevelEntitlements.test.js
+++ b/src/utils/getLevelEntitlements.test.js
@@ -13,6 +13,7 @@ describe('getLevelEntitlements', () => {
sprinkler: true,
},
sprinklerRange: 2,
+ stageFocusType: {},
tools: {
SHOVEL: true,
},
diff --git a/src/utils/index.js b/src/utils/index.js
index c524ca0b6..03ea00d27 100644
--- a/src/utils/index.js
+++ b/src/utils/index.js
@@ -7,8 +7,6 @@
/** @typedef {import("../index").farmhand.priceEvent} farmhand.priceEvent */
/** @typedef {import("../index").farmhand.cowBreedingPen} farmhand.cowBreedingPen */
/** @typedef {import("../enums").cropLifeStage} farmhand.cropLifeStage */
-/** @typedef {import("../enums").toolLevel} farmhand.toolLevel */
-/** @typedef {import("../enums").toolType} farmhand.toolType */
/** @typedef {import("../components/Farmhand/Farmhand").farmhand.state} farmhand.state */
/**
@@ -48,7 +46,6 @@ import {
itemType,
stageFocusType,
standardCowColors,
- toolLevel,
} from '../enums'
import {
BREAKPOINTS,
@@ -70,6 +67,8 @@ import {
INFINITE_STORAGE_LIMIT,
INITIAL_FIELD_HEIGHT,
INITIAL_FIELD_WIDTH,
+ INITIAL_FOREST_HEIGHT,
+ INITIAL_FOREST_WIDTH,
INITIAL_STORAGE_LIMIT,
LARGEST_PURCHASABLE_FIELD_SIZE,
MALE_COW_WEIGHT_MULTIPLIER,
@@ -192,6 +191,12 @@ export const createNewField = () =>
.fill(undefined)
.map(() => new Array(INITIAL_FIELD_WIDTH).fill(null))
+export const createNewForest = () => {
+ return new Array(INITIAL_FOREST_HEIGHT)
+ .fill(undefined)
+ .map(() => new Array(INITIAL_FOREST_WIDTH).fill(null))
+}
+
/**
* @param {number} number
* @param {string} format
@@ -961,21 +966,6 @@ export const computeMarketPositions = (
return acc
}, {})
-/**
- * @param {Object.} currentToolLevels
- * @param {farmhand.toolType} toolType
- * @returns {farmhand.state}
- */
-export const unlockTool = (currentToolLevels, toolType) => {
- if (currentToolLevels[toolType] === toolLevel.UNAVAILABLE) {
- return Object.assign({}, currentToolLevels, {
- [toolType]: toolLevel.DEFAULT,
- })
- }
-
- return currentToolLevels
-}
-
/**
* @param {farmhand.state} state
* @return {farmhand.state}