@jeremyckahn/farmhand 1.24.1 → 1.24.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/assets/{index-B03HVYBS.js → index-C9kG86vs.js} +11 -11
  2. package/dist/assets/{index-B03HVYBS.js.map → index-C9kG86vs.js.map} +1 -1
  3. package/dist/index.html +1 -1
  4. package/dist/service-worker.js +1 -1
  5. package/dist/service-worker.js.map +1 -1
  6. package/package.json +1 -1
  7. package/src/components/Cellar/Keg.tsx +4 -1
  8. package/src/components/ContextPane/ContextPane.test.tsx +57 -0
  9. package/src/components/ContextPane/ContextPane.tsx +38 -2
  10. package/src/components/Farmhand/FarmhandService.test.ts +23 -0
  11. package/src/components/Farmhand/FarmhandService.ts +12 -16
  12. package/src/components/Item/Item.test.tsx +29 -0
  13. package/src/components/Item/Item.tsx +14 -2
  14. package/src/components/Navigation/Navigation.test.tsx +0 -3
  15. package/src/components/Navigation/Navigation.tsx +3 -34
  16. package/src/components/OnlinePeersView/OnlinePeer/OnlinePeer.tsx +1 -2
  17. package/src/data/items.ts +17 -0
  18. package/src/farmhand.d.ts +1 -0
  19. package/src/game-logic/reducers/harvestPlot.test.ts +18 -0
  20. package/src/game-logic/reducers/harvestPlot.ts +5 -6
  21. package/src/game-logic/reducers/modifyCow.test.ts +33 -0
  22. package/src/game-logic/reducers/modifyCow.ts +4 -9
  23. package/src/game-logic/reducers/purchaseItem.test.ts +129 -0
  24. package/src/game-logic/reducers/purchaseItem.ts +9 -2
  25. package/src/utils/getItemById.test.ts +18 -0
  26. package/src/utils/getItemById.ts +14 -0
  27. package/src/utils/getPlotImage.ts +1 -3
  28. package/src/utils/transformStateDataForImport.test.ts +9 -0
  29. package/src/utils/transformStateDataForImport.ts +9 -12
package/dist/index.html CHANGED
@@ -75,7 +75,7 @@
75
75
  gtag('config', analyticsID)
76
76
  })()
77
77
  </script>
78
- <script type="module" crossorigin src="./assets/index-B03HVYBS.js"></script>
78
+ <script type="module" crossorigin src="./assets/index-C9kG86vs.js"></script>
79
79
  <link rel="stylesheet" crossorigin href="./assets/index-CaYdR-lC.css">
80
80
  <link rel="manifest" href="./manifest.webmanifest"></head>
81
81
  <body>
@@ -1,3 +1,3 @@
1
- if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,o)=>{const r=e||("document"in self?document.currentScript.src:"")||location.href;if(s[r])return;let d={};const l=e=>i(e,r),t={module:{uri:r},exports:d,require:l};s[r]=Promise.all(n.map(e=>t[e]||l(e))).then(e=>(o(...e),d))}}define(["./workbox-2fbc6a65"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.precacheAndRoute([{url:"index.html",revision:"8f7a31c2cb5a071e7d839eac539d63e0"},{url:"electron.js",revision:"b0456826f47539f6c1957c3add5eb959"},{url:"assets/workbox-window.prod.es5-Bd17z0YL.js",revision:null},{url:"assets/index-CaYdR-lC.css",revision:null},{url:"assets/index-B03HVYBS.js",revision:null},{url:"favicon.ico",revision:"445bb19d4a8fd46eb433dd43cbdce691"},{url:"logo192.png",revision:"70746d790b8990639d1390337d77d81a"},{url:"logo512.png",revision:"b044ab46b3f33f0e2d313d961bf1f654"},{url:"logoMaskable.png",revision:"23be58ccf16df9f43658aad3da30056d"},{url:"manifest.webmanifest",revision:"3ad071a00582451c9cdeb827b37b9ebe"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html")))});
1
+ if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,o)=>{const r=e||("document"in self?document.currentScript.src:"")||location.href;if(s[r])return;let d={};const l=e=>i(e,r),t={module:{uri:r},exports:d,require:l};s[r]=Promise.all(n.map(e=>t[e]||l(e))).then(e=>(o(...e),d))}}define(["./workbox-2fbc6a65"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.precacheAndRoute([{url:"index.html",revision:"f5ce8c2d4c7358bc1ce3bb4838506540"},{url:"electron.js",revision:"b0456826f47539f6c1957c3add5eb959"},{url:"assets/workbox-window.prod.es5-Bd17z0YL.js",revision:null},{url:"assets/index-CaYdR-lC.css",revision:null},{url:"assets/index-C9kG86vs.js",revision:null},{url:"favicon.ico",revision:"445bb19d4a8fd46eb433dd43cbdce691"},{url:"logo192.png",revision:"70746d790b8990639d1390337d77d81a"},{url:"logo512.png",revision:"b044ab46b3f33f0e2d313d961bf1f654"},{url:"logoMaskable.png",revision:"23be58ccf16df9f43658aad3da30056d"},{url:"manifest.webmanifest",revision:"3ad071a00582451c9cdeb827b37b9ebe"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html")))});
2
2
  //# sourceMappingURL=service-worker.js.map
3
3
  //# sourceMappingURL=service-worker.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"service-worker.js","sources":["../../../../../tmp/e11c5a6afdb2093b6b4b31cba4d4ce43/service-worker.js"],"sourcesContent":["import {precacheAndRoute as workbox_precaching_precacheAndRoute} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-precaching/precacheAndRoute.mjs';\nimport {cleanupOutdatedCaches as workbox_precaching_cleanupOutdatedCaches} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-precaching/cleanupOutdatedCaches.mjs';\nimport {registerRoute as workbox_routing_registerRoute} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-routing/registerRoute.mjs';\nimport {NavigationRoute as workbox_routing_NavigationRoute} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-routing/NavigationRoute.mjs';\nimport {createHandlerBoundToURL as workbox_precaching_createHandlerBoundToURL} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-precaching/createHandlerBoundToURL.mjs';/**\n * Welcome to your Workbox-powered service worker!\n *\n * You'll need to register this file in your web app.\n * See https://goo.gl/nhQhGp\n *\n * The rest of the code is auto-generated. Please don't update this file\n * directly; instead, make changes to your Workbox build configuration\n * and re-run your build process.\n * See https://goo.gl/2aRDsh\n */\n\n\n\n\nself.addEventListener('message', (event) => {\n if (event.data && event.data.type === 'SKIP_WAITING') {\n self.skipWaiting();\n }\n});\n\n/**\n * The precacheAndRoute() method efficiently caches and responds to\n * requests for URLs in the manifest.\n * See https://goo.gl/S9QRab\n */\nworkbox_precaching_precacheAndRoute([\n {\n \"url\": \"index.html\",\n \"revision\": \"8f7a31c2cb5a071e7d839eac539d63e0\"\n },\n {\n \"url\": \"electron.js\",\n \"revision\": \"b0456826f47539f6c1957c3add5eb959\"\n },\n {\n \"url\": \"assets/workbox-window.prod.es5-Bd17z0YL.js\",\n \"revision\": null\n },\n {\n \"url\": \"assets/index-CaYdR-lC.css\",\n \"revision\": null\n },\n {\n \"url\": \"assets/index-B03HVYBS.js\",\n \"revision\": null\n },\n {\n \"url\": \"favicon.ico\",\n \"revision\": \"445bb19d4a8fd46eb433dd43cbdce691\"\n },\n {\n \"url\": \"logo192.png\",\n \"revision\": \"70746d790b8990639d1390337d77d81a\"\n },\n {\n \"url\": \"logo512.png\",\n \"revision\": \"b044ab46b3f33f0e2d313d961bf1f654\"\n },\n {\n \"url\": \"logoMaskable.png\",\n \"revision\": \"23be58ccf16df9f43658aad3da30056d\"\n },\n {\n \"url\": \"manifest.webmanifest\",\n \"revision\": \"3ad071a00582451c9cdeb827b37b9ebe\"\n }\n], {});\nworkbox_precaching_cleanupOutdatedCaches();workbox_routing_registerRoute(new workbox_routing_NavigationRoute(workbox_precaching_createHandlerBoundToURL(\"index.html\")));\n\n\n"],"names":["self","addEventListener","event","data","type","skipWaiting","workbox_precaching_precacheAndRoute","url","revision","workbox_precaching_cleanupOutdatedCaches","workbox_routing_registerRoute","workbox_routing_NavigationRoute","workbox_precaching_createHandlerBoundToURL"],"mappings":"inBAmBAA,KAAKC,iBAAiB,UAAYC,IAC5BA,EAAMC,MAA4B,iBAApBD,EAAMC,KAAKC,MAC3BJ,KAAKK,gBASTC,EAAAA,iBAAoC,CAClC,CACEC,IAAO,aACPC,SAAY,oCAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,6CACPC,SAAY,MAEd,CACED,IAAO,4BACPC,SAAY,MAEd,CACED,IAAO,2BACPC,SAAY,MAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,mBACPC,SAAY,oCAEd,CACED,IAAO,uBACPC,SAAY,qCAEb,CAAA,GACHC,EAAAA,wBAA2CC,EAAAA,cAA8B,IAAIC,EAAAA,gBAAgCC,0BAA2C"}
1
+ {"version":3,"file":"service-worker.js","sources":["../../../../../tmp/5a47feca681fd14fa867c579d860224e/service-worker.js"],"sourcesContent":["import {precacheAndRoute as workbox_precaching_precacheAndRoute} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-precaching/precacheAndRoute.mjs';\nimport {cleanupOutdatedCaches as workbox_precaching_cleanupOutdatedCaches} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-precaching/cleanupOutdatedCaches.mjs';\nimport {registerRoute as workbox_routing_registerRoute} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-routing/registerRoute.mjs';\nimport {NavigationRoute as workbox_routing_NavigationRoute} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-routing/NavigationRoute.mjs';\nimport {createHandlerBoundToURL as workbox_precaching_createHandlerBoundToURL} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-precaching/createHandlerBoundToURL.mjs';/**\n * Welcome to your Workbox-powered service worker!\n *\n * You'll need to register this file in your web app.\n * See https://goo.gl/nhQhGp\n *\n * The rest of the code is auto-generated. Please don't update this file\n * directly; instead, make changes to your Workbox build configuration\n * and re-run your build process.\n * See https://goo.gl/2aRDsh\n */\n\n\n\n\nself.addEventListener('message', (event) => {\n if (event.data && event.data.type === 'SKIP_WAITING') {\n self.skipWaiting();\n }\n});\n\n/**\n * The precacheAndRoute() method efficiently caches and responds to\n * requests for URLs in the manifest.\n * See https://goo.gl/S9QRab\n */\nworkbox_precaching_precacheAndRoute([\n {\n \"url\": \"index.html\",\n \"revision\": \"f5ce8c2d4c7358bc1ce3bb4838506540\"\n },\n {\n \"url\": \"electron.js\",\n \"revision\": \"b0456826f47539f6c1957c3add5eb959\"\n },\n {\n \"url\": \"assets/workbox-window.prod.es5-Bd17z0YL.js\",\n \"revision\": null\n },\n {\n \"url\": \"assets/index-CaYdR-lC.css\",\n \"revision\": null\n },\n {\n \"url\": \"assets/index-C9kG86vs.js\",\n \"revision\": null\n },\n {\n \"url\": \"favicon.ico\",\n \"revision\": \"445bb19d4a8fd46eb433dd43cbdce691\"\n },\n {\n \"url\": \"logo192.png\",\n \"revision\": \"70746d790b8990639d1390337d77d81a\"\n },\n {\n \"url\": \"logo512.png\",\n \"revision\": \"b044ab46b3f33f0e2d313d961bf1f654\"\n },\n {\n \"url\": \"logoMaskable.png\",\n \"revision\": \"23be58ccf16df9f43658aad3da30056d\"\n },\n {\n \"url\": \"manifest.webmanifest\",\n \"revision\": \"3ad071a00582451c9cdeb827b37b9ebe\"\n }\n], {});\nworkbox_precaching_cleanupOutdatedCaches();workbox_routing_registerRoute(new workbox_routing_NavigationRoute(workbox_precaching_createHandlerBoundToURL(\"index.html\")));\n\n\n"],"names":["self","addEventListener","event","data","type","skipWaiting","workbox_precaching_precacheAndRoute","url","revision","workbox_precaching_cleanupOutdatedCaches","workbox_routing_registerRoute","workbox_routing_NavigationRoute","workbox_precaching_createHandlerBoundToURL"],"mappings":"inBAmBAA,KAAKC,iBAAiB,UAAYC,IAC5BA,EAAMC,MAA4B,iBAApBD,EAAMC,KAAKC,MAC3BJ,KAAKK,gBASTC,EAAAA,iBAAoC,CAClC,CACEC,IAAO,aACPC,SAAY,oCAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,6CACPC,SAAY,MAEd,CACED,IAAO,4BACPC,SAAY,MAEd,CACED,IAAO,2BACPC,SAAY,MAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,mBACPC,SAAY,oCAEd,CACED,IAAO,uBACPC,SAAY,qCAEb,CAAA,GACHC,EAAAA,wBAA2CC,EAAAA,cAA8B,IAAIC,EAAAA,gBAAgCC,0BAA2C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeremyckahn/farmhand",
3
- "version": "1.24.1",
3
+ "version": "1.24.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -24,7 +24,10 @@ export function Keg({ keg }: { keg: farmhand.keg }) {
24
24
 
25
25
  const item = itemsMap[keg.itemId]
26
26
 
27
- // TODO: Add a defensive check in case item is undefined (e.g. keg.itemId is invalid) to prevent runtime crash when accessing item.id
27
+ if (!item) {
28
+ throw new Error(`Keg references unknown item id: ${keg.itemId}`)
29
+ }
30
+
28
31
  let imageSrc = items[item.id as keyof typeof items]
29
32
 
30
33
  const recipeName = getKegDisplayName(item)
@@ -24,6 +24,8 @@ vi.mock('../Inventory/index.js', () => ({
24
24
  const defaultProps = {
25
25
  playerInventory: [],
26
26
  stageFocus: stageFocusType.NONE,
27
+ inventory: [],
28
+ inventoryLimit: INFINITE_STORAGE_LIMIT,
27
29
  }
28
30
 
29
31
  const baseItemProps = {
@@ -69,6 +71,61 @@ describe('ContextPane', () => {
69
71
  expect(screen.getByText('Corn')).toBeInTheDocument()
70
72
  })
71
73
 
74
+ test('displays inventory capacity for a limited inventory', () => {
75
+ const inventory = [
76
+ testItem({ id: 'carrot', name: 'Carrot', quantity: 5 }),
77
+ testItem({ id: 'corn', name: 'Corn', quantity: 3 }),
78
+ ] as unknown as farmhand.state['inventory']
79
+
80
+ render(
81
+ <ContextPane
82
+ {...defaultProps}
83
+ inventory={inventory}
84
+ inventoryLimit={10}
85
+ />
86
+ )
87
+
88
+ const capacity = screen.getByText('Capacity: 8 / 10')
89
+
90
+ expect(capacity).toBeInTheDocument()
91
+ expect(capacity).not.toHaveClass('is-inventory-full')
92
+ })
93
+
94
+ test('marks inventory capacity as full when the limit is reached', () => {
95
+ const inventory = [
96
+ testItem({ id: 'carrot', name: 'Carrot', quantity: 5 }),
97
+ testItem({ id: 'corn', name: 'Corn', quantity: 5 }),
98
+ ] as unknown as farmhand.state['inventory']
99
+
100
+ render(
101
+ <ContextPane
102
+ {...defaultProps}
103
+ inventory={inventory}
104
+ inventoryLimit={10}
105
+ />
106
+ )
107
+
108
+ expect(screen.getByText('Capacity: 10 / 10')).toHaveClass(
109
+ 'is-inventory-full'
110
+ )
111
+ })
112
+
113
+ test('does not display inventory capacity for unlimited storage', () => {
114
+ const inventory = [
115
+ testItem({ id: 'carrot', name: 'Carrot', quantity: 5 }),
116
+ ] as unknown as farmhand.state['inventory']
117
+
118
+ render(
119
+ <ContextPane
120
+ {...defaultProps}
121
+ inventory={inventory}
122
+ inventoryLimit={INFINITE_STORAGE_LIMIT}
123
+ />
124
+ )
125
+
126
+ expect(screen.queryByText(/Capacity:/)).not.toBeInTheDocument()
127
+ })
128
+
72
129
  describe('conditional UI based on stageFocus', () => {
73
130
  test('shows inventory view when stageFocus is SHOP', () => {
74
131
  const playerInventory = [
@@ -1,12 +1,19 @@
1
1
  import React, { memo } from 'react'
2
+ import classNames from 'classnames'
2
3
 
3
4
  import FarmhandContext from '../Farmhand/Farmhand.context.js'
4
5
  import Inventory from '../Inventory/index.js'
5
6
  import CowPenContextMenu from '../CowPenContextMenu/index.js'
6
7
  import FarmhandShuffleContextMenu from '../FarmhandShuffleContextMenu/index.js'
7
8
  import { stageFocusType } from '../../enums.js'
8
- import { Div } from '../Elements/index.js'
9
+ import { Div, H3 } from '../Elements/index.js'
9
10
  import { centerTabsSx } from '../../styles/sx.js'
11
+ import { colors } from '../../styles/tokens.js'
12
+
13
+ import { integerString } from '../../utils/integerString.js'
14
+ import { doesInventorySpaceRemain } from '../../utils/doesInventorySpaceRemain.js'
15
+ import { inventorySpaceConsumed } from '../../utils/inventorySpaceConsumed.js'
16
+ import { INFINITE_STORAGE_LIMIT } from '../../constants.js'
10
17
 
11
18
  export const PlayerInventory = memo<{ playerInventory: farmhand.item[] }>(
12
19
  /**
@@ -29,10 +36,19 @@ player's inventory.
29
36
  export const ContextPane = ({
30
37
  playerInventory,
31
38
  stageFocus,
39
+ inventory,
40
+ inventoryLimit,
32
41
  }: {
33
42
  playerInventory: farmhand.item[]
34
43
  stageFocus: string
44
+ inventory: farmhand.state['inventory']
45
+ inventoryLimit: number
35
46
  }) => {
47
+ const isInventoryFull = !doesInventorySpaceRemain({
48
+ inventory,
49
+ inventoryLimit,
50
+ })
51
+
36
52
  return (
37
53
  <Div
38
54
  className="ContextPane"
@@ -40,6 +56,8 @@ export const ContextPane = ({
40
56
  ...centerTabsSx,
41
57
  margin: 0,
42
58
  '& h2': { margin: '0.5em 0 1em', textAlign: 'center' },
59
+ '& .inventory-info': { fontSize: '1em' },
60
+ '& .inventory-title': { marginBottom: '0.5em' },
43
61
  }}
44
62
  >
45
63
  {stageFocus === stageFocusType.COW_PEN ? (
@@ -48,7 +66,25 @@ export const ContextPane = ({
48
66
  <FarmhandShuffleContextMenu />
49
67
  ) : (
50
68
  <>
51
- <h2>Inventory</h2>
69
+ <h2 className="inventory-title">Inventory</h2>
70
+
71
+ {inventoryLimit > INFINITE_STORAGE_LIMIT && (
72
+ <H3
73
+ {...{
74
+ className: classNames('inventory-info', {
75
+ 'is-inventory-full': isInventoryFull,
76
+ }),
77
+ }}
78
+ sx={{
79
+ color: isInventoryFull ? colors.error : undefined,
80
+ textAlign: 'center',
81
+ }}
82
+ >
83
+ Capacity: {integerString(inventorySpaceConsumed(inventory))} /{' '}
84
+ {integerString(inventoryLimit)}
85
+ </H3>
86
+ )}
87
+
52
88
  {/*
53
89
  // NOTE: Weird ignore comment syntax and formatting is needed here.
54
90
  // See: https://stackoverflow.com/a/56913087/470685 */}
@@ -33,6 +33,29 @@ describe('FarmhandService', () => {
33
33
  })
34
34
  })
35
35
 
36
+ describe('inventory containing an unknown item id', () => {
37
+ const inventory = [{ quantity: 1, id: 'not-a-real-item' }]
38
+ const message = 'Unknown item id: not-a-real-item'
39
+
40
+ test('computePlayerInventory throws an informative error', () => {
41
+ expect(() =>
42
+ FarmhandService.computePlayerInventory(inventory, {})
43
+ ).toThrow(message)
44
+ })
45
+
46
+ test('getFieldToolInventory throws an informative error', () => {
47
+ expect(() => FarmhandService.getFieldToolInventory(inventory)).toThrow(
48
+ message
49
+ )
50
+ })
51
+
52
+ test('getPlantableCropInventory throws an informative error', () => {
53
+ expect(() =>
54
+ FarmhandService.getPlantableCropInventory(inventory)
55
+ ).toThrow(message)
56
+ })
57
+ })
58
+
36
59
  describe('getFieldToolInventory', () => {
37
60
  test('selects field tools from inventory', () => {
38
61
  const fieldToolInventory = FarmhandService.getFieldToolInventory([
@@ -1,5 +1,6 @@
1
1
  import { itemsMap } from '../../data/maps.js'
2
2
  import { fieldMode, itemType } from '../../enums.js'
3
+ import { getItemById } from '../../utils/getItemById.js'
3
4
  import { getItemCurrentValue } from '../../utils/getItemCurrentValue.js'
4
5
  import { memoize } from '../../utils/memoize.js'
5
6
 
@@ -12,23 +13,22 @@ export class FarmhandService {
12
13
  inventory: farmhand.state['inventory'],
13
14
  valueAdjustments: Record<string, number>
14
15
  ): farmhand.item[] =>
15
- // TODO: Add a defensive check if itemsMap[id] is undefined to prevent runtime crash on invalid items
16
- inventory.map(({ quantity, id }: { quantity: number; id: string }) => ({
17
- quantity,
18
- ...itemsMap[id as keyof typeof itemsMap],
19
- value: getItemCurrentValue(
20
- itemsMap[id as keyof typeof itemsMap],
21
- valueAdjustments
22
- ),
23
- }))
16
+ inventory.map(({ quantity, id }: { quantity: number; id: string }) => {
17
+ const item = getItemById(id)
18
+
19
+ return {
20
+ quantity,
21
+ ...item,
22
+ value: getItemCurrentValue(item, valueAdjustments),
23
+ }
24
+ })
24
25
  )
25
26
 
26
27
  static getFieldToolInventory = memoize(
27
28
  (inventory: farmhand.state['inventory']): farmhand.item[] =>
28
29
  inventory
29
30
  .filter(({ id }: { id: string }) => {
30
- // TODO: Defensive check if item exists in itemsMap to prevent crashes on undefined itemsMap[id]
31
- const item = itemsMap[id as keyof typeof itemsMap]
31
+ const item = getItemById(id)
32
32
 
33
33
  return (
34
34
  typeof item.enablesFieldMode === 'string' &&
@@ -48,11 +48,7 @@ export class FarmhandService {
48
48
  static getPlantableCropInventory = memoize(
49
49
  (inventory: farmhand.state['inventory']): farmhand.item[] =>
50
50
  inventory
51
- .filter(
52
- ({ id }: { id: string }) =>
53
- // TODO: Add a defensive check to verify itemsMap[id] exists before accessing isPlantableCrop
54
- itemsMap[id as keyof typeof itemsMap].isPlantableCrop
55
- )
51
+ .filter(({ id }: { id: string }) => getItemById(id).isPlantableCrop)
56
52
  .map(({ id, quantity }: { id: string; quantity: number }) => ({
57
53
  ...itemsMap[id as keyof typeof itemsMap],
58
54
  quantity,
@@ -3,8 +3,10 @@ import { render, screen, waitFor, within } from '@testing-library/react'
3
3
  import userEvent from '@testing-library/user-event'
4
4
 
5
5
  import { testItem } from '../../test-utils/index.js'
6
+ import { testState } from '../../test-utils/index.js'
6
7
  import { season } from '../../enums.js'
7
8
  import { carrotSeed } from '../../data/crops/index.js'
9
+ import { huggingMachine } from '../../data/items.js'
8
10
 
9
11
  import { INFINITE_STORAGE_LIMIT } from '../../constants.js'
10
12
 
@@ -74,6 +76,33 @@ describe('Item', () => {
74
76
  })
75
77
  })
76
78
 
79
+ test('limits the purchase quantity using the item maximum', async () => {
80
+ render(
81
+ <Item
82
+ {...{
83
+ ...props,
84
+ adjustedValue: 500,
85
+ item: huggingMachine,
86
+ money: 10_000,
87
+ purchaseState: testState({
88
+ inventory: [],
89
+ inventoryLimit: INFINITE_STORAGE_LIMIT,
90
+ purchasedCowPen: 1,
91
+ }),
92
+ }}
93
+ />
94
+ )
95
+
96
+ const quantity = screen.getByRole('textbox')
97
+ const increment = screen.getByRole('button', { name: 'Increment' })
98
+
99
+ for (let clickCount = 0; clickCount < 9; clickCount += 1) {
100
+ await userEvent.click(increment)
101
+ }
102
+
103
+ expect(quantity).toHaveValue('10')
104
+ })
105
+
77
106
  describe('prices', () => {
78
107
  beforeEach(() => {
79
108
  render(<Item {...{ ...props, money: 100 }} />)
@@ -117,6 +117,7 @@ export interface ItemProps {
117
117
  adjustedValue?: number
118
118
  previousDayAdjustedValue?: number | null
119
119
  maxQuantityPlayerCanPurchase?: number
120
+ purchaseState?: farmhand.state
120
121
  }
121
122
 
122
123
  export const Item = ({
@@ -137,6 +138,7 @@ export const Item = ({
137
138
  playerInventoryQuantities,
138
139
  showQuantity,
139
140
  valueAdjustments,
141
+ purchaseState,
140
142
 
141
143
  item: {
142
144
  description,
@@ -163,7 +165,10 @@ export const Item = ({
163
165
  0,
164
166
  Math.min(
165
167
  Math.floor(money / adjustedValue),
166
- inventorySpaceRemaining({ inventory, inventoryLimit })
168
+ inventorySpaceRemaining({ inventory, inventoryLimit }),
169
+ purchaseState && item?.getMaxPurchaseQuantity
170
+ ? item.getMaxPurchaseQuantity(purchaseState)
171
+ : Infinity
167
172
  )
168
173
  ),
169
174
  }: ItemProps) => {
@@ -461,7 +466,14 @@ export default function Consumer(props: Partial<ItemProps>) {
461
466
  return (
462
467
  <FarmhandContext.Consumer>
463
468
  {({ gameState, handlers }) => (
464
- <Item {...{ ...gameState, ...handlers, ...props }} />
469
+ <Item
470
+ {...{
471
+ ...gameState,
472
+ ...handlers,
473
+ ...props,
474
+ purchaseState: gameState,
475
+ }}
476
+ />
465
477
  )}
466
478
  </FarmhandContext.Consumer>
467
479
  )
@@ -4,7 +4,6 @@ import userEvent from '@testing-library/user-event'
4
4
  import { vi } from 'vitest'
5
5
 
6
6
  import { dialogView, stageFocusType } from '../../enums.js'
7
- import { INFINITE_STORAGE_LIMIT } from '../../constants.js'
8
7
  import { noop } from '../../utils/noop.js'
9
8
  import FarmhandContext, {
10
9
  createContextData,
@@ -27,8 +26,6 @@ const defaultProps = {
27
26
  handleOnlineToggleChange: noop,
28
27
  handleRoomChange: noop,
29
28
  handleViewChange: noop,
30
- inventory: [],
31
- inventoryLimit: INFINITE_STORAGE_LIMIT,
32
29
  itemsSold: {},
33
30
  isChatAvailable: false,
34
31
  isDialogViewOpen: false,
@@ -27,21 +27,15 @@ import {
27
27
  } from '../PixelIcon/index.js'
28
28
 
29
29
  import { MAX_ROOM_NAME_LENGTH } from '../../common/constants.js'
30
- import {
31
- DEFAULT_ROOM,
32
- INFINITE_STORAGE_LIMIT,
33
- STAGE_TITLE_MAP,
34
- } from '../../constants.js'
30
+ import { DEFAULT_ROOM, STAGE_TITLE_MAP } from '../../constants.js'
35
31
  import { dialogView } from '../../enums.js'
36
- import { doesInventorySpaceRemain } from '../../utils/doesInventorySpaceRemain.js'
37
32
  import { integerString } from '../../utils/integerString.js'
38
- import { inventorySpaceConsumed } from '../../utils/inventorySpaceConsumed.js'
39
33
  import FarmhandContext from '../Farmhand/Farmhand.context.js'
40
34
 
41
- import { breakpoints, colors, fonts } from '../../styles/tokens.js'
35
+ import { breakpoints, fonts } from '../../styles/tokens.js'
42
36
  import AccountingView from '../AccountingView/index.js'
43
37
  import AchievementsView from '../AchievementsView/index.js'
44
- import { H3, Header } from '../Elements/index.js'
38
+ import { Header } from '../Elements/index.js'
45
39
  import KeybindingsView from '../KeybindingsView/index.js'
46
40
  import LogView from '../LogView/index.js'
47
41
  import OnlinePeersView from '../OnlinePeersView/index.js'
@@ -260,8 +254,6 @@ export const Navigation = ({
260
254
  handleOnlineToggleChange,
261
255
  handleRoomChange,
262
256
  handleViewChange,
263
- inventory,
264
- inventoryLimit,
265
257
  isChatAvailable,
266
258
  isDialogViewOpen,
267
259
  isOnline,
@@ -286,8 +278,6 @@ export const Navigation = ({
286
278
  handleOnlineToggleChange: (checked: boolean) => void
287
279
  handleRoomChange: (newRoom: string) => void
288
280
  handleViewChange: (event: any) => void
289
- inventory: farmhand.state['inventory']
290
- inventoryLimit: number
291
281
  isChatAvailable: boolean
292
282
  isDialogViewOpen: boolean
293
283
  isOnline: boolean
@@ -369,7 +359,6 @@ export const Navigation = ({
369
359
  position: 'relative',
370
360
  },
371
361
  },
372
- '& .inventory-info': { fontSize: '1em' },
373
362
  '& .MuiSelect-select': { padding: '1em' },
374
363
  '& .chat-placeholder': {
375
364
  fontSize: '0.75em',
@@ -396,26 +385,6 @@ export const Navigation = ({
396
385
  room,
397
386
  }}
398
387
  />
399
- {inventoryLimit > INFINITE_STORAGE_LIMIT && (
400
- <H3
401
- {...{
402
- className: classNames('inventory-info', {
403
- 'is-inventory-full': !doesInventorySpaceRemain({
404
- inventory,
405
- inventoryLimit,
406
- }),
407
- }),
408
- }}
409
- sx={{
410
- color: !doesInventorySpaceRemain({ inventory, inventoryLimit })
411
- ? colors.error
412
- : undefined,
413
- }}
414
- >
415
- Inventory: {integerString(inventorySpaceConsumed(inventory))} /{' '}
416
- {integerString(inventoryLimit)}
417
- </H3>
418
- )}
419
388
 
420
389
  <Select
421
390
  variant="standard"
@@ -30,8 +30,7 @@ const OnlinePeer = ({
30
30
  subheader: (
31
31
  <div>
32
32
  <p>Day: {integerString(dayCount)}</p>
33
- {/* TODO: Remove `?? 0` after 10/24 */}
34
- <p>Level: {integerString(levelAchieved(experience ?? 0))}</p>
33
+ <p>Level: {integerString(levelAchieved(experience))}</p>
35
34
  <p>Money: {moneyString(money)}</p>
36
35
  </div>
37
36
  ),
package/src/data/items.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  COW_FEED_ITEM_ID,
4
4
  HUGGING_MACHINE_ITEM_ID,
5
5
  INITIAL_SPRINKLER_RANGE,
6
+ PURCHASEABLE_COW_PENS,
6
7
  } from '../constants.js'
7
8
 
8
9
  const { freeze } = Object
@@ -197,6 +198,22 @@ export const huggingMachine: farmhand.item = freeze({
197
198
  name: 'Hugging Machine',
198
199
  type: HUGGING_MACHINE,
199
200
  value: 500,
201
+ getMaxPurchaseQuantity: (state: farmhand.state) => {
202
+ const huggingMachinesInInventory =
203
+ state.inventory.find(i => i.id === HUGGING_MACHINE_ITEM_ID)?.quantity ?? 0
204
+
205
+ const huggingMachinesInUse = state.cowInventory.filter(
206
+ cow => cow.isUsingHuggingMachine
207
+ ).length
208
+
209
+ const cowCapacity =
210
+ PURCHASEABLE_COW_PENS.get(state.purchasedCowPen)?.cows ?? 0
211
+
212
+ return Math.max(
213
+ 0,
214
+ cowCapacity - huggingMachinesInInventory - huggingMachinesInUse
215
+ )
216
+ },
200
217
  })
201
218
 
202
219
  /**
package/src/farmhand.d.ts CHANGED
@@ -63,6 +63,7 @@ declare namespace farmhand {
63
63
  // ore - see applyDestructionYield in reducers/helpers.tsx for the
64
64
  // shared logic that applies it.
65
65
  destructionYield?: { itemId: string; quantity: number }
66
+ getMaxPurchaseQuantity?: (state: state) => number
66
67
  }
67
68
 
68
69
  interface seedItem extends item {
@@ -99,6 +99,24 @@ describe('harvestPlot', () => {
99
99
  })
100
100
  })
101
101
 
102
+ describe('inventory has less space than the harvest yield', () => {
103
+ test('only counts the crops that were actually received', () => {
104
+ const inputState = testState({
105
+ field: [[testCrop({ itemId: 'sample-crop-1', daysWatered: 4 })]],
106
+ inventory: [{ id: 'sample-crop-2', quantity: 4 }],
107
+ inventoryLimit: 5,
108
+ toolLevels: toolLevelsBronze,
109
+ })
110
+ // The bronze scythe yields 2 crops but only 1 slot is free
111
+ const { cropsHarvested } = harvestPlot(inputState, 0, 0)
112
+
113
+ expect(cropsHarvested).toEqual({
114
+ ...testState().cropsHarvested,
115
+ SAMPLE_CROP_TYPE_1: 1,
116
+ })
117
+ })
118
+ })
119
+
102
120
  describe('there is insufficient inventory space', () => {
103
121
  test('no-ops', () => {
104
122
  const inputState = testState({
@@ -7,6 +7,7 @@ import {
7
7
  } from '../../enums.js'
8
8
  import { itemsMap } from '../../data/maps.js'
9
9
  import { doesInventorySpaceRemain } from '../../utils/doesInventorySpaceRemain.js'
10
+ import { inventorySpaceRemaining } from '../../utils/inventorySpaceRemaining.js'
10
11
  import { getCropLifeStage } from '../../utils/getCropLifeStage.js'
11
12
  import { getPlotContentType } from '../../utils/getPlotContentType.js'
12
13
  import { getSeedItemIdFromFinalStageCropItemId } from '../../utils/getSeedItemIdFromFinalStageCropItemId.js'
@@ -43,7 +44,10 @@ function getHarvestedQuantity(state: farmhand.state): number {
43
44
  amount = 1
44
45
  }
45
46
 
46
- return amount
47
+ // Cap the yield to the space actually available so that callers only count
48
+ // crops the player received, not crops that addItemToInventory would
49
+ // silently drop because the inventory is nearly full.
50
+ return Math.min(amount, inventorySpaceRemaining(state))
47
51
  }
48
52
 
49
53
  function harvestCrops(
@@ -94,11 +98,6 @@ function harvestCrops(
94
98
 
95
99
  const { cropsHarvested } = state
96
100
 
97
- // TODO: cropsHarvested is incremented by the full harvestedQuantity even
98
- // when addItemToInventory silently caps how much is actually added because
99
- // the inventory is nearly full, so this counter can over-count relative to
100
- // what the player actually received. See the equivalent fix applied to
101
- // treeFruitsHarvested in harvestForestPlot.ts for the pattern to follow.
102
101
  return {
103
102
  ...state,
104
103
  cropsHarvested: {
@@ -0,0 +1,33 @@
1
+ import { getCowStub } from '../../test-utils/stubs/cowStub.js'
2
+ import { testState } from '../../test-utils/index.js'
3
+
4
+ import { modifyCow } from './modifyCow.js'
5
+
6
+ describe('modifyCow', () => {
7
+ test('updates the matching cow', () => {
8
+ const cow1 = getCowStub({ id: 'cow-1', name: 'Bessie' })
9
+ const cow2 = getCowStub({ id: 'cow-2', name: 'Daisy' })
10
+ const state = testState({ cowInventory: [cow1, cow2] })
11
+
12
+ const { cowInventory } = modifyCow(state, 'cow-2', () => ({
13
+ name: 'Clover',
14
+ }))
15
+
16
+ expect(cowInventory).toEqual([cow1, { ...cow2, name: 'Clover' }])
17
+ })
18
+
19
+ test('does not mutate the original cowInventory', () => {
20
+ const cow = getCowStub({ id: 'cow-1' })
21
+ const state = testState({ cowInventory: [cow] })
22
+
23
+ modifyCow(state, 'cow-1', () => ({ name: 'Clover' }))
24
+
25
+ expect(state.cowInventory).toEqual([cow])
26
+ })
27
+
28
+ test('no-ops if the cow does not exist', () => {
29
+ const state = testState({ cowInventory: [getCowStub({ id: 'cow-1' })] })
30
+
31
+ expect(modifyCow(state, 'missing', () => ({ name: 'x' }))).toBe(state)
32
+ })
33
+ })
@@ -1,4 +1,5 @@
1
- // TODO: Add tests for this reducer
1
+ import { findCowById } from '../../utils/findCowById.js'
2
+
2
3
  /**
3
4
  * @param fn Function that takes a cow and returns the modified cow or undefined.
4
5
  */
@@ -7,21 +8,15 @@ export const modifyCow = (
7
8
  cowId: string,
8
9
  fn: (cow: farmhand.cow) => Partial<farmhand.cow>
9
10
  ): farmhand.state => {
10
- const cowInventory = [...state.cowInventory]
11
-
12
- // TODO: Use the findCowById util here.
13
- const cow = cowInventory.find(({ id }) => id === cowId)
11
+ const cow = findCowById(state.cowInventory, cowId)
14
12
 
15
13
  if (!cow) {
16
14
  return state
17
15
  }
18
16
 
17
+ const cowInventory = [...state.cowInventory]
19
18
  const cowIndex = cowInventory.indexOf(cow)
20
19
 
21
- if (cowIndex === -1) {
22
- return state
23
- }
24
-
25
20
  cowInventory[cowIndex] = {
26
21
  ...cow,
27
22
  ...fn(cow),