@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
@@ -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),
@@ -1,4 +1,6 @@
1
1
  import { INFINITE_STORAGE_LIMIT } from '../../constants.js'
2
+ import { huggingMachine } from '../../data/items.js'
3
+ import { generateCow } from '../../utils/generateCow.js'
2
4
  import { testItem, testState } from '../../test-utils/index.js'
3
5
 
4
6
  import { purchaseItem } from './purchaseItem.js'
@@ -81,5 +83,132 @@ describe('purchaseItem', () => {
81
83
  })
82
84
  })
83
85
  })
86
+
87
+ test('respects an item maximum purchase quantity', () => {
88
+ const item = testItem({
89
+ id: 'sample-item-1',
90
+ getMaxPurchaseQuantity: () => 2,
91
+ })
92
+
93
+ expect(purchaseItem(state, item, 5)).toMatchObject({
94
+ inventory: [{ id: 'sample-item-1', quantity: 2 }],
95
+ todaysPurchases: { 'sample-item-1': 2 },
96
+ money: 8,
97
+ })
98
+ })
99
+
100
+ test('does not purchase when an item maximum is negative', () => {
101
+ const item = testItem({
102
+ id: 'sample-item-1',
103
+ getMaxPurchaseQuantity: () => -2,
104
+ })
105
+
106
+ expect(purchaseItem(state, item, 1)).toMatchObject({
107
+ inventory: [],
108
+ todaysPurchases: {},
109
+ money: 10,
110
+ })
111
+ })
112
+
113
+ describe('the item has a maximum purchase quantity', () => {
114
+ test.each([
115
+ [1, 10],
116
+ [2, 20],
117
+ [3, 30],
118
+ ])(
119
+ 'limits Hugging Machines to the Cow Pen capacity of %i cows',
120
+ (purchasedCowPen, cowCapacity) => {
121
+ state = testState({
122
+ inventory: [],
123
+ money: 10_000,
124
+ purchasedCowPen,
125
+ valueAdjustments: { [huggingMachine.id]: 1 },
126
+ })
127
+
128
+ expect(huggingMachine.getMaxPurchaseQuantity?.(state)).toEqual(
129
+ cowCapacity
130
+ )
131
+ }
132
+ )
133
+
134
+ test('limits the purchase to the remaining Cow Pen capacity', () => {
135
+ state = testState({
136
+ inventory: [{ id: huggingMachine.id, quantity: 9 }],
137
+ money: 10_000,
138
+ purchasedCowPen: 1,
139
+ valueAdjustments: { [huggingMachine.id]: 1 },
140
+ })
141
+
142
+ expect(purchaseItem(state, huggingMachine, 2)).toMatchObject({
143
+ inventory: [{ id: huggingMachine.id, quantity: 10 }],
144
+ todaysPurchases: { [huggingMachine.id]: 1 },
145
+ money: 9_500,
146
+ pendingPeerMessages: [
147
+ { message: 'purchased 1 unit of Hugging Machine.' },
148
+ ],
149
+ })
150
+ })
151
+
152
+ test('does not purchase a Hugging Machine when capacity is full', () => {
153
+ state = testState({
154
+ inventory: [{ id: huggingMachine.id, quantity: 10 }],
155
+ money: 10_000,
156
+ purchasedCowPen: 1,
157
+ valueAdjustments: { [huggingMachine.id]: 1 },
158
+ })
159
+
160
+ expect(purchaseItem(state, huggingMachine, 1)).toMatchObject({
161
+ inventory: [{ id: huggingMachine.id, quantity: 10 }],
162
+ todaysPurchases: {},
163
+ money: 10_000,
164
+ })
165
+ })
166
+
167
+ test('does not purchase a Hugging Machine when already over capacity', () => {
168
+ state = testState({
169
+ inventory: [{ id: huggingMachine.id, quantity: 11 }],
170
+ money: 10_000,
171
+ purchasedCowPen: 1,
172
+ valueAdjustments: { [huggingMachine.id]: 1 },
173
+ })
174
+
175
+ expect(purchaseItem(state, huggingMachine, 1)).toMatchObject({
176
+ inventory: [{ id: huggingMachine.id, quantity: 11 }],
177
+ todaysPurchases: {},
178
+ money: 10_000,
179
+ })
180
+ })
181
+
182
+ test('does not purchase a Hugging Machine when all capacity is in use', () => {
183
+ state = testState({
184
+ cowInventory: Array.from({ length: 6 }, () =>
185
+ generateCow({ isUsingHuggingMachine: true })
186
+ ),
187
+ inventory: [{ id: huggingMachine.id, quantity: 4 }],
188
+ money: 10_000,
189
+ purchasedCowPen: 1,
190
+ valueAdjustments: { [huggingMachine.id]: 1 },
191
+ })
192
+
193
+ expect(purchaseItem(state, huggingMachine, 1)).toMatchObject({
194
+ inventory: [{ id: huggingMachine.id, quantity: 4 }],
195
+ todaysPurchases: {},
196
+ money: 10_000,
197
+ })
198
+ })
199
+
200
+ test('does not purchase a Hugging Machine without a Cow Pen', () => {
201
+ state = testState({
202
+ money: 10_000,
203
+ valueAdjustments: { [huggingMachine.id]: 1 },
204
+ })
205
+
206
+ expect(purchaseItem(state, huggingMachine, 1)).toMatchObject({
207
+ inventory: [],
208
+ todaysPurchases: {},
209
+ money: 10_000,
210
+ })
211
+ })
212
+ })
84
213
  })
85
214
  })
@@ -13,7 +13,14 @@ export const purchaseItem = (
13
13
  howMany: number = 1
14
14
  ): farmhand.state => {
15
15
  const { money, todaysPurchases, valueAdjustments } = state
16
- const numberOfItemsToAdd = Math.min(howMany, inventorySpaceRemaining(state))
16
+
17
+ const getMaxPurchaseQuantity =
18
+ item.getMaxPurchaseQuantity?.(state) ?? Infinity
19
+
20
+ const numberOfItemsToAdd = Math.max(
21
+ 0,
22
+ Math.min(howMany, inventorySpaceRemaining(state), getMaxPurchaseQuantity)
23
+ )
17
24
 
18
25
  if (numberOfItemsToAdd === 0) {
19
26
  return state
@@ -28,7 +35,7 @@ export const purchaseItem = (
28
35
 
29
36
  state = prependPendingPeerMessage(
30
37
  state,
31
- PURCHASED_ITEM_PEER_NOTIFICATION('', howMany, item)
38
+ PURCHASED_ITEM_PEER_NOTIFICATION('', numberOfItemsToAdd, item)
32
39
  )
33
40
 
34
41
  return addItemToInventory(
@@ -55,6 +55,37 @@ describe('bootup', () => {
55
55
  })
56
56
  })
57
57
 
58
+ test('does not announce already-learned recipes as newly learned when booting from a save file', async () => {
59
+ const loadedState = saveDataStubFactory({
60
+ dayCount: 10,
61
+ learnedRecipes: { bread: true, butter: true },
62
+ })
63
+
64
+ await farmhandStub({
65
+ localforage: {
66
+ getItem: () => Promise.resolve(loadedState),
67
+ setItem: (_key: string, data: unknown) => Promise.resolve(data),
68
+ },
69
+ })
70
+
71
+ await waitFor(() => {
72
+ expect(
73
+ screen.getByText('Day 10', {
74
+ exact: false,
75
+ selector: '.day-and-progress-container span',
76
+ })
77
+ ).toBeInTheDocument()
78
+ })
79
+
80
+ // Notifications are deferred via setTimeout(0) and flushed after boot, so
81
+ // give them a moment to (incorrectly) appear.
82
+ await new Promise(resolve => setTimeout(resolve, 100))
83
+
84
+ expect(
85
+ screen.queryByText(/You learned the recipes/)
86
+ ).not.toBeInTheDocument()
87
+ })
88
+
58
89
  test(
59
90
  'pending notifications for the loaded day are not shown again the next day',
60
91
  async () => {
@@ -0,0 +1,18 @@
1
+ import { carrot } from '../data/items.js'
2
+
3
+ import { getItemById } from './getItemById.js'
4
+
5
+ vitest.mock('../data/maps.js')
6
+ vitest.mock('../data/items.js')
7
+
8
+ describe('getItemById', () => {
9
+ test('returns the item for a known id', () => {
10
+ expect(getItemById('carrot')).toEqual(carrot)
11
+ })
12
+
13
+ test('throws an informative error for an unknown id', () => {
14
+ expect(() => getItemById('not-a-real-item')).toThrow(
15
+ 'Unknown item id: not-a-real-item'
16
+ )
17
+ })
18
+ })
@@ -0,0 +1,14 @@
1
+ import { itemsMap } from '../data/maps.js'
2
+
3
+ /**
4
+ * @throws If no item exists for the provided id.
5
+ */
6
+ export const getItemById = (id: string): farmhand.item => {
7
+ const item = itemsMap[id as keyof typeof itemsMap]
8
+
9
+ if (!item) {
10
+ throw new Error(`Unknown item id: ${id}`)
11
+ }
12
+
13
+ return item
14
+ }
@@ -64,9 +64,7 @@ export const getPlotImage = (
64
64
 
65
65
  if (getPlotContentType(plotContents) === itemType.WEED) {
66
66
  const weedColors = ['yellow', 'orange', 'pink']
67
- // TODO: Handle negative coordinates by using Math.abs(x * y) to avoid
68
- // negative modulo index.
69
- const color = weedColors[(x * y) % weedColors.length]
67
+ const color = weedColors[Math.abs(x * y) % weedColors.length]
70
68
 
71
69
  return (itemImages as Record<string, string>)[`weed-${color}`]
72
70
  }
@@ -27,6 +27,15 @@ describe('transformStateDataForImport', () => {
27
27
  expect(sanitizedState).toEqual(stateWithoutVersion)
28
28
  })
29
29
 
30
+ test('it does not throw if cowBreedingPen and cowInventory are missing', () => {
31
+ delete state.cowBreedingPen
32
+ delete state.cowInventory
33
+
34
+ const sanitizedState = transformStateDataForImport(state as any)
35
+
36
+ expect(sanitizedState.cowBreedingPen).toBeUndefined()
37
+ })
38
+
30
39
  test('it calculates experience from itemsSold if experience is 0', () => {
31
40
  state.experience = 0
32
41
  state.itemsSold = {
@@ -34,20 +34,17 @@ export const transformStateDataForImport = (
34
34
  // cowInventory, but at least one player's game somehow got into that state.
35
35
  // This block detects such an invalid state and corrects it.
36
36
  {
37
- // TODO: Add defensive check safeguards for sanitizedState.cowBreedingPen
38
- // and sanitizedState.cowInventory to prevent TypeError crashes during
39
- // corrupt/legacy state imports.
40
- const { cowId1, cowId2 } =
41
- sanitizedState.cowBreedingPen as farmhand.state['cowBreedingPen']
37
+ const { cowId1, cowId2 } = (sanitizedState.cowBreedingPen ?? {}) as Partial<
38
+ farmhand.state['cowBreedingPen']
39
+ >
42
40
 
43
- const cowPenIdMap = (sanitizedState.cowInventory as farmhand.cow[]).reduce(
44
- (acc: Record<string, farmhand.cow>, cow: farmhand.cow) => {
45
- acc[cow.id] = cow
41
+ const cowPenIdMap = (
42
+ (sanitizedState.cowInventory ?? []) as farmhand.cow[]
43
+ ).reduce((acc: Record<string, farmhand.cow>, cow: farmhand.cow) => {
44
+ acc[cow.id] = cow
46
45
 
47
- return acc
48
- },
49
- {}
50
- )
46
+ return acc
47
+ }, {})
51
48
 
52
49
  const isCowInBreedingPenMissingFromInventory = [cowId1, cowId2].some(
53
50
  cowId => {