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}