@jeremyckahn/farmhand 1.24.0 → 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 (32) hide show
  1. package/dist/assets/{index-BZUcLFKn.js → index-C9kG86vs.js} +11 -11
  2. package/dist/assets/{index-BZUcLFKn.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/Farmhand.tsx +8 -1
  11. package/src/components/Farmhand/FarmhandService.test.ts +23 -0
  12. package/src/components/Farmhand/FarmhandService.ts +12 -16
  13. package/src/components/Farmhand/useFarmhand.ts +16 -4
  14. package/src/components/Item/Item.test.tsx +29 -0
  15. package/src/components/Item/Item.tsx +14 -2
  16. package/src/components/Navigation/Navigation.test.tsx +0 -3
  17. package/src/components/Navigation/Navigation.tsx +3 -34
  18. package/src/components/OnlinePeersView/OnlinePeer/OnlinePeer.tsx +1 -2
  19. package/src/data/items.ts +17 -0
  20. package/src/farmhand.d.ts +1 -0
  21. package/src/game-logic/reducers/harvestPlot.test.ts +18 -0
  22. package/src/game-logic/reducers/harvestPlot.ts +5 -6
  23. package/src/game-logic/reducers/modifyCow.test.ts +33 -0
  24. package/src/game-logic/reducers/modifyCow.ts +4 -9
  25. package/src/game-logic/reducers/purchaseItem.test.ts +129 -0
  26. package/src/game-logic/reducers/purchaseItem.ts +9 -2
  27. package/src/shell/bootup.test.ts +31 -0
  28. package/src/utils/getItemById.test.ts +18 -0
  29. package/src/utils/getItemById.ts +14 -0
  30. package/src/utils/getPlotImage.ts +1 -3
  31. package/src/utils/transformStateDataForImport.test.ts +9 -0
  32. 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-BZUcLFKn.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:"9e3f9a73088913e04fed57d368fedc7d"},{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-BZUcLFKn.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/1349b09c556e7604b02b1b3b11d90cba/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\": \"9e3f9a73088913e04fed57d368fedc7d\"\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-BZUcLFKn.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.0",
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 */}
@@ -18,6 +18,7 @@ import {
18
18
  } from '../PixelIcon/index.js'
19
19
 
20
20
  import { Z_INDEX } from '../../constants.js'
21
+ import { stageFocusType } from '../../enums.js'
21
22
  import theme, { blueStripeBg } from '../../mui-theme.js'
22
23
  import { Div } from '../Elements/index.js'
23
24
  import { fillSx } from '../../styles/sx.js'
@@ -77,6 +78,12 @@ const Farmhand = ({
77
78
  isChatAvailable,
78
79
  } = useFarmhand(props)
79
80
 
81
+ // Ending the day mid-match is only blocked on the Shuffle stage itself;
82
+ // a match left in progress must not hide the button everywhere else.
83
+ const isEndDayHidden =
84
+ state.stageFocus === stageFocusType.FARMHAND_SHUFFLE &&
85
+ gameState.farmhandShuffle.isMatchInProgress
86
+
80
87
  return (
81
88
  <GlobalHotKeys
82
89
  allowChanges={true}
@@ -268,7 +275,7 @@ const Farmhand = ({
268
275
  </Fab>
269
276
  </div>
270
277
  </div>
271
- {!gameState.farmhandShuffle.isMatchInProgress && (
278
+ {!isEndDayHidden && (
272
279
  <Tooltip
273
280
  placement="left"
274
281
  title={
@@ -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,
@@ -532,10 +532,14 @@ export const useFarmhand = (props: FarmhandProps) => {
532
532
 
533
533
  const keyHandlers = useMemo(() => {
534
534
  const map: Record<string, () => void> = {
535
- // The End Day button is hidden while a Farmhand Shuffle match is in
536
- // progress (see Farmhand.tsx), so its hotkey must not bypass that.
535
+ // The End Day button is hidden on the Farmhand Shuffle stage while a
536
+ // match is in progress (see Farmhand.tsx), so its hotkey must not
537
+ // bypass that.
537
538
  incrementDay: () => {
538
- if (!state.farmhandShuffle.isMatchInProgress) {
539
+ if (!(
540
+ state.stageFocus === stageFocusType.FARMHAND_SHUFFLE &&
541
+ state.farmhandShuffle.isMatchInProgress
542
+ )) {
539
543
  incrementDay()
540
544
  }
541
545
  },
@@ -587,6 +591,7 @@ export const useFarmhand = (props: FarmhandProps) => {
587
591
  focusPreviousView,
588
592
  handlers,
589
593
  state.farmhandShuffle.isMatchInProgress,
594
+ state.stageFocus,
590
595
  state.toolLevels,
591
596
  viewList,
592
597
  clearPersistedData,
@@ -863,7 +868,14 @@ export const useFarmhand = (props: FarmhandProps) => {
863
868
  }
864
869
 
865
870
  showInventoryFullNotifications(prevState, state)
866
- showRecipeLearnedNotifications(prevState)
871
+
872
+ // prevState.hasBooted is false only for the render where the persisted
873
+ // game is restored on page load. Diffing against that pre-boot state
874
+ // (which has no learned recipes) would report every recipe the player has
875
+ // ever learned as newly learned, on every page load.
876
+ if (prevState.hasBooted) {
877
+ showRecipeLearnedNotifications(prevState)
878
+ }
867
879
  }, [
868
880
  state,
869
881
  prevState,
@@ -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: {