@jeremyckahn/farmhand 1.20.0 → 1.20.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 (47) hide show
  1. package/dist/assets/index-dORrBwcH.js +844 -0
  2. package/dist/assets/{index-VncPzOlx.js.map → index-dORrBwcH.js.map} +1 -1
  3. package/dist/assets/{workbox-window.prod.es5-Cch4wiA5.js → workbox-window.prod.es5-Bd17z0YL.js} +2 -2
  4. package/dist/assets/{workbox-window.prod.es5-Cch4wiA5.js.map → workbox-window.prod.es5-Bd17z0YL.js.map} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/service-worker.js +1 -1
  7. package/dist/service-worker.js.map +1 -1
  8. package/package.json +3 -3
  9. package/src/components/Cellar/Cellar.tsx +7 -5
  10. package/src/components/Cellar/Keg.tsx +2 -3
  11. package/src/components/Farmhand/hooks/useFarmhandDayAdvancement.ts +2 -1
  12. package/src/components/Farmhand/hooks/useFarmhandNetwork.ts +2 -1
  13. package/src/components/Farmhand/useFarmhand.ts +103 -31
  14. package/src/components/NotificationSystem/NotificationSystem.test.tsx +53 -32
  15. package/src/components/NotificationSystem/NotificationSystem.tsx +20 -9
  16. package/src/components/Shop/Shop.tsx +8 -3
  17. package/src/components/Stage/Stage.tsx +9 -2
  18. package/src/components/Workshop/Workshop.tsx +11 -3
  19. package/src/data/recipes.ts +30 -0
  20. package/src/game-logic/reducers/processCellar.test.ts +70 -0
  21. package/src/game-logic/reducers/processCellar.ts +32 -2
  22. package/src/handlers/ui-events.tsx +8 -3
  23. package/src/hooks/useTabQueryParam.test.tsx +86 -0
  24. package/src/hooks/useTabQueryParam.ts +57 -0
  25. package/src/img/dishes/apple-juice.piskel +1 -0
  26. package/src/img/dishes/apple-juice.png +0 -0
  27. package/src/img/dishes/grape-syrup.piskel +1 -0
  28. package/src/img/dishes/grape-syrup.png +0 -0
  29. package/src/img/index.ts +4 -0
  30. package/src/img/ui/floorboard.piskel +1 -0
  31. package/src/img/ui/floorboard.png +0 -0
  32. package/src/setupTests.ts +6 -0
  33. package/src/shell/hash-view-restoration.test.ts +89 -0
  34. package/src/shell/navigation.test.ts +4 -4
  35. package/src/templates.ts +20 -0
  36. package/src/utils/getGrowingPhase.test.ts +15 -0
  37. package/src/utils/getGrowingPhase.ts +2 -11
  38. package/src/utils/getGrowingPhaseForTimeline.ts +1 -1
  39. package/src/utils/getKegDisplayName.test.ts +17 -0
  40. package/src/utils/getKegDisplayName.ts +10 -0
  41. package/src/utils/getValidatedStageFocusFromHash.test.ts +72 -0
  42. package/src/utils/getValidatedStageFocusFromHash.ts +43 -0
  43. package/src/utils/getViewList.test.ts +73 -0
  44. package/src/utils/getViewList.ts +46 -0
  45. package/src/utils/hashQueryParams.test.ts +153 -0
  46. package/src/utils/hashQueryParams.ts +72 -0
  47. package/dist/assets/index-VncPzOlx.js +0 -842
@@ -18,11 +18,11 @@ import { Div } from '../Elements/index.js'
18
18
  import { breakpoints, layout } from '../../styles/tokens.js'
19
19
 
20
20
  import brownDotBg from '../../img/ui/brown-dot-bg.png'
21
+ import floorboardBg from '../../img/ui/floorboard.png'
21
22
  import jackOLanternBg from '../../img/ui/jack-o-lantern-bg.png'
22
23
  import winterBg from '../../img/ui/winter-bg.png'
23
24
  import yellowDotBg from '../../img/ui/yellow-dot-bg.png'
24
25
  import lavenderDotBg from '../../img/ui/lavender-dot-bg.png'
25
- import greenDotBg from '../../img/ui/green-dot-bg.png'
26
26
  import grassBg from '../../img/ui/grass.png'
27
27
  import forestFloorBg from '../../img/ui/forest-floor.png'
28
28
 
@@ -67,7 +67,7 @@ export const Stage = ({
67
67
  : brownDotBg,
68
68
  [stageFocusType.SHOP]: yellowDotBg,
69
69
  [stageFocusType.WORKSHOP]: lavenderDotBg,
70
- [stageFocusType.CELLAR]: greenDotBg,
70
+ [stageFocusType.CELLAR]: floorboardBg,
71
71
  [stageFocusType.FIELD]: grassBg,
72
72
  [stageFocusType.COW_PEN]: grassBg,
73
73
  [stageFocusType.FOREST]: forestFloorBg,
@@ -111,6 +111,13 @@ export const Stage = ({
111
111
  },
112
112
  }
113
113
  : {}),
114
+ // floorboard.png is a 144x48 (3:1) tile, unlike every other
115
+ // background image here which is square - keep it at the same 2x
116
+ // pixel-art scale as the rest (48px source -> 96px displayed) by
117
+ // matching that same ratio instead of the shared square size.
118
+ ...(stageFocus === stageFocusType.CELLAR
119
+ ? { backgroundSize: '288px 96px' }
120
+ : {}),
114
121
  '& h3': { textAlign: 'center' },
115
122
  '& h2': { fontSize: '1.2em' },
116
123
  '& .view-title': {
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react'
1
+ import React from 'react'
2
2
  import { number, object } from 'prop-types'
3
3
  import Tab from '@mui/material/Tab/index.js'
4
4
  import Tabs from '@mui/material/Tabs/index.js'
@@ -12,6 +12,7 @@ import FarmhandContext from '../Farmhand/Farmhand.context.js'
12
12
  import { Div } from '../Elements/index.js'
13
13
 
14
14
  import { centerTabsSx } from '../../styles/sx.js'
15
+ import { useTabQueryParam } from '../../hooks/useTabQueryParam.js'
15
16
 
16
17
  import { a11yProps } from './TabPanel/index.js'
17
18
 
@@ -35,8 +36,6 @@ const Workshop = ({
35
36
  purchasedWoodChipper,
36
37
  toolLevels,
37
38
  }: WorkshopProps) => {
38
- const [currentTab, setCurrentTab] = useState(0)
39
-
40
39
  const learnedKitchenRecipes = Object.keys(learnedRecipes).filter(
41
40
  recipeId => recipesMap[recipeId].recipeType === recipeType.KITCHEN
42
41
  )
@@ -58,6 +57,15 @@ const Workshop = ({
58
57
  const recyclingTabIndex = showForge ? 2 : 1
59
58
  const woodChipperTabIndex = recyclingTabIndex + (purchasedComposter ? 1 : 0)
60
59
 
60
+ const [currentTab, setCurrentTab] = useTabQueryParam(
61
+ [
62
+ 'Kitchen',
63
+ showForge ? 'Forge' : '',
64
+ purchasedComposter ? 'Recycling' : '',
65
+ purchasedWoodChipper ? 'Wood Chipper' : '',
66
+ ].filter(Boolean)
67
+ )
68
+
61
69
  return (
62
70
  <Div className="Workshop" sx={centerTabsSx}>
63
71
  <Tabs
@@ -587,6 +587,36 @@ export const applePie: farmhand.recipe = convertToRecipe({
587
587
  isPie: true,
588
588
  })
589
589
 
590
+ /**
591
+ * @property farmhand.module:recipes.appleJuice
592
+ */
593
+ export const appleJuice: farmhand.recipe = convertToRecipe({
594
+ id: 'apple-juice',
595
+ name: 'Apple Juice',
596
+ ingredients: {
597
+ [items.apple.id]: 6,
598
+ [sugar.id]: 2,
599
+ },
600
+ condition: state =>
601
+ (state.itemsSold[items.apple.id] || 0) >= 60 &&
602
+ (state.itemsSold[sugar.id] || 0) >= 10,
603
+ recipeType: recipeType.KITCHEN,
604
+ })
605
+
606
+ /**
607
+ * @property farmhand.module:recipes.grapeSyrup
608
+ */
609
+ export const grapeSyrup: farmhand.recipe = convertToRecipe({
610
+ id: 'grape-syrup',
611
+ name: 'Grape Syrup',
612
+ ingredients: {
613
+ [items.grapeCabernetSauvignon.id]: 8,
614
+ },
615
+ condition: state =>
616
+ (state.itemsSold[items.grapeCabernetSauvignon.id] || 0) >= 25,
617
+ recipeType: recipeType.KITCHEN,
618
+ })
619
+
590
620
  /**
591
621
  * @property farmhand.module:recipes.bananaBread
592
622
  */
@@ -1,5 +1,7 @@
1
1
  import { testState } from '../../test-utils/index.js'
2
2
  import { carrot, garlic } from '../../data/items.js'
3
+ import { wineChardonnay } from '../../data/recipes.js'
4
+ import { KEGS_READY_TO_SELL } from '../../templates.js'
3
5
 
4
6
  import { processCellar } from './processCellar.js'
5
7
 
@@ -23,4 +25,72 @@ describe('processCellar', () => {
23
25
  })
24
26
  )
25
27
  })
28
+
29
+ test('notifies when a wine keg becomes ready', () => {
30
+ const result = processCellar(
31
+ testState({
32
+ cellarInventory: [
33
+ { itemId: wineChardonnay.id, daysUntilMature: 1, id: 'wine-id' },
34
+ ],
35
+ })
36
+ )
37
+
38
+ expect(result.newDayNotifications).toEqual([
39
+ {
40
+ message: KEGS_READY_TO_SELL('', {
41
+ [wineChardonnay.name]: 1,
42
+ }),
43
+ severity: 'success',
44
+ },
45
+ ])
46
+ })
47
+
48
+ test('does not notify for a fermented crop keg becoming ready', () => {
49
+ const result = processCellar(
50
+ testState({
51
+ cellarInventory: [
52
+ { itemId: carrot.id, daysUntilMature: 1, id: 'carrot-id' },
53
+ ],
54
+ })
55
+ )
56
+
57
+ expect(result.newDayNotifications).toEqual([])
58
+ })
59
+
60
+ test('does not notify for a keg that is already ready or not yet ready', () => {
61
+ const result = processCellar(
62
+ testState({
63
+ cellarInventory: [
64
+ { itemId: carrot.id, daysUntilMature: 4, id: 'carrot-id' },
65
+ { itemId: garlic.id, daysUntilMature: 0, id: 'garlic-id' },
66
+ ],
67
+ })
68
+ )
69
+
70
+ expect(result.newDayNotifications).toEqual([])
71
+ })
72
+
73
+ test('does not re-notify for a wine keg that is already ready', () => {
74
+ const result = processCellar(
75
+ testState({
76
+ cellarInventory: [
77
+ { itemId: wineChardonnay.id, daysUntilMature: 0, id: 'wine-id' },
78
+ ],
79
+ })
80
+ )
81
+
82
+ expect(result.newDayNotifications).toEqual([])
83
+ })
84
+
85
+ test('does not notify for a wine keg that is not yet ready', () => {
86
+ const result = processCellar(
87
+ testState({
88
+ cellarInventory: [
89
+ { itemId: wineChardonnay.id, daysUntilMature: 5, id: 'wine-id' },
90
+ ],
91
+ })
92
+ )
93
+
94
+ expect(result.newDayNotifications).toEqual([])
95
+ })
26
96
  })
@@ -1,3 +1,8 @@
1
+ import { itemsMap } from '../../data/maps.js'
2
+ import { wineService } from '../../services/wine.js'
3
+ import { KEGS_READY_TO_SELL } from '../../templates.js'
4
+ import { getKegDisplayName } from '../../utils/getKegDisplayName.js'
5
+
1
6
  import { processCellarSpoilage } from './processCellarSpoilage.js'
2
7
 
3
8
  /**
@@ -8,17 +13,42 @@ export const processCellar = (state: farmhand.state) => {
8
13
  const { cellarInventory } = state
9
14
 
10
15
  const newCellarInventory = [...cellarInventory]
16
+ const kegsReady: Record<string, number> = {}
11
17
 
12
18
  for (let i = 0; i < newCellarInventory.length; i++) {
13
19
  const keg = newCellarInventory[i]
20
+ const daysUntilMature = keg.daysUntilMature - 1
21
+ const item = itemsMap[keg.itemId]
22
+
23
+ // Only wine is worth calling out here - it's a deliberate long-term
24
+ // investment (kegs that never spoil and appreciate in value). Generic
25
+ // fermented crops still spoil if left too long, so their own spoilage
26
+ // notification already covers that urgency without needing a separate
27
+ // "ready" notification too.
28
+ const isNotifiableKeg = wineService.isWineRecipe(item)
29
+
30
+ if (isNotifiableKeg && keg.daysUntilMature > 0 && daysUntilMature <= 0) {
31
+ const displayName = getKegDisplayName(item)
32
+
33
+ kegsReady[displayName] = (kegsReady[displayName] || 0) + 1
34
+ }
14
35
 
15
36
  newCellarInventory[i] = {
16
37
  ...keg,
17
- daysUntilMature: keg.daysUntilMature - 1,
38
+ daysUntilMature,
18
39
  }
19
40
  }
20
41
 
21
- state = { ...state, cellarInventory: newCellarInventory }
42
+ const newDayNotifications = [...state.newDayNotifications]
43
+
44
+ if (Object.keys(kegsReady).length) {
45
+ newDayNotifications.push({
46
+ message: KEGS_READY_TO_SELL('', kegsReady),
47
+ severity: 'success',
48
+ })
49
+ }
50
+
51
+ state = { ...state, cellarInventory: newCellarInventory, newDayNotifications }
22
52
 
23
53
  return state
24
54
  }
@@ -15,6 +15,7 @@ import {
15
15
  UPDATE_AVAILABLE,
16
16
  } from '../strings.js'
17
17
  import { getTreeLifeStage } from '../utils/getTreeLifeStage.js'
18
+ import { withCurrentHashQuery } from '../utils/hashQueryParams.js'
18
19
  import { isPlantedTree } from '../utils/isPlantedTree.js'
19
20
  import { moneyTotal } from '../utils/moneyTotal.js'
20
21
  import { reduceByPersistedKeys } from '../utils/reduceByPersistedKeys.js'
@@ -474,12 +475,14 @@ export default {
474
475
  this.setState(({ room, cowIdOfferedForTrade }: farmhand.state) =>
475
476
  goOnline
476
477
  ? {
477
- redirect: `/online/${encodeURIComponent(room)}`,
478
+ redirect: withCurrentHashQuery(
479
+ `/online/${encodeURIComponent(room)}`
480
+ ),
478
481
  cowIdOfferedForTrade,
479
482
  isOnline: true,
480
483
  }
481
484
  : {
482
- redirect: '/',
485
+ redirect: withCurrentHashQuery('/'),
483
486
  cowIdOfferedForTrade: '',
484
487
  }
485
488
  )
@@ -488,7 +491,9 @@ export default {
488
491
  handleRoomChange(this: Farmhand, room: string) {
489
492
  this.setState(() => ({
490
493
  room,
491
- redirect: `/online/${encodeURIComponent(room.trim() || DEFAULT_ROOM)}`,
494
+ redirect: withCurrentHashQuery(
495
+ `/online/${encodeURIComponent(room.trim() || DEFAULT_ROOM)}`
496
+ ),
492
497
  }))
493
498
  },
494
499
 
@@ -0,0 +1,86 @@
1
+ import React from 'react'
2
+ import { render, screen } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+
5
+ import { useTabQueryParam } from './useTabQueryParam.js'
6
+
7
+ const TestComponent = ({ tabLabels }: { tabLabels: string[] }) => {
8
+ const [currentTab, setCurrentTab] = useTabQueryParam(tabLabels)
9
+
10
+ return (
11
+ <div>
12
+ <span data-testid="current-tab">{currentTab}</span>
13
+ {tabLabels.map((label, index) => (
14
+ <button key={label} onClick={() => setCurrentTab(index)}>
15
+ {label}
16
+ </button>
17
+ ))}
18
+ </div>
19
+ )
20
+ }
21
+
22
+ describe('useTabQueryParam', () => {
23
+ test('defaults to tab 0 when the hash has no tab param', () => {
24
+ render(<TestComponent tabLabels={['Seeds', 'Supplies', 'Upgrades']} />)
25
+
26
+ expect(screen.getByTestId('current-tab')).toHaveTextContent('0')
27
+ })
28
+
29
+ test('restores the tab index matching the hash tab param', () => {
30
+ window.history.replaceState(
31
+ {},
32
+ '',
33
+ `${window.location.pathname}#?tab=Upgrades`
34
+ )
35
+
36
+ render(<TestComponent tabLabels={['Seeds', 'Supplies', 'Upgrades']} />)
37
+
38
+ expect(screen.getByTestId('current-tab')).toHaveTextContent('2')
39
+ })
40
+
41
+ test('falls back to tab 0 when the hash tab param does not match any label', () => {
42
+ window.history.replaceState(
43
+ {},
44
+ '',
45
+ `${window.location.pathname}#?tab=NotARealTab`
46
+ )
47
+
48
+ render(<TestComponent tabLabels={['Seeds', 'Supplies', 'Upgrades']} />)
49
+
50
+ expect(screen.getByTestId('current-tab')).toHaveTextContent('0')
51
+ })
52
+
53
+ test('changing tabs updates both the rendered state and the hash', async () => {
54
+ render(<TestComponent tabLabels={['Seeds', 'Supplies', 'Upgrades']} />)
55
+
56
+ await userEvent.click(screen.getByRole('button', { name: 'Upgrades' }))
57
+
58
+ expect(screen.getByTestId('current-tab')).toHaveTextContent('2')
59
+ expect(window.location.hash).toEqual('#?tab=Upgrades')
60
+ })
61
+
62
+ test('re-derives the tab index when a tab is inserted after mount (e.g. save data loading asynchronously adds a tab)', () => {
63
+ window.history.replaceState(
64
+ {},
65
+ '',
66
+ `${window.location.pathname}#?tab=Upgrades`
67
+ )
68
+
69
+ const { rerender } = render(
70
+ <TestComponent tabLabels={['Seeds', 'Supplies', 'Upgrades']} />
71
+ )
72
+
73
+ // Upgrades starts at index 2, matching the initial (pre-load) label list.
74
+ expect(screen.getByTestId('current-tab')).toHaveTextContent('2')
75
+
76
+ // A "Saplings" tab appears after unlock-dependent save data loads,
77
+ // shifting Upgrades from index 2 to index 3.
78
+ rerender(
79
+ <TestComponent
80
+ tabLabels={['Seeds', 'Saplings', 'Supplies', 'Upgrades']}
81
+ />
82
+ )
83
+
84
+ expect(screen.getByTestId('current-tab')).toHaveTextContent('3')
85
+ })
86
+ })
@@ -0,0 +1,57 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ import {
4
+ getHashQueryParams,
5
+ setHashQueryParam,
6
+ } from '../utils/hashQueryParams.js'
7
+
8
+ const TAB_QUERY_PARAM_NAME = 'tab'
9
+
10
+ const getTabIndexFromHash = (tabLabels: readonly string[]): number => {
11
+ const tabLabelFromUrl = getHashQueryParams().get(TAB_QUERY_PARAM_NAME)
12
+ const tabIndexFromUrl = tabLabels.indexOf(tabLabelFromUrl ?? '')
13
+
14
+ return tabIndexFromUrl === -1 ? 0 : tabIndexFromUrl
15
+ }
16
+
17
+ /**
18
+ * Drop-in replacement for `useState(0)` for a MUI `Tabs`/`Tab` `value`,
19
+ * that also mirrors the active tab's label into the URL hash's `tab`
20
+ * query param so a page reload restores the same tab. Tabs are matched by
21
+ * label rather than raw index, since some screens conditionally omit tabs
22
+ * (e.g. Shop's Saplings tab), which shifts indices around depending on
23
+ * what's unlocked.
24
+ *
25
+ * `tabLabels` can legitimately change shape after the initial render (e.g.
26
+ * a screen mounts before its unlock-dependent tabs are known, then a
27
+ * persisted save loads a moment later and adds one) - re-deriving the
28
+ * index whenever the label list itself changes keeps the restored tab
29
+ * pointing at the right label instead of freezing on its first-render
30
+ * position.
31
+ */
32
+ export const useTabQueryParam = (
33
+ tabLabels: readonly string[]
34
+ ): [number, (tabIndex: number) => void] => {
35
+ const [currentTab, setCurrentTabState] = useState(() =>
36
+ getTabIndexFromHash(tabLabels)
37
+ )
38
+
39
+ const tabLabelsKey = tabLabels.join('|')
40
+
41
+ useEffect(() => {
42
+ setCurrentTabState(getTabIndexFromHash(tabLabels))
43
+ // eslint-disable-next-line react-hooks/exhaustive-deps
44
+ }, [tabLabelsKey])
45
+
46
+ const setCurrentTab = (tabIndex: number) => {
47
+ setCurrentTabState(tabIndex)
48
+
49
+ const tabLabel = tabLabels[tabIndex]
50
+
51
+ if (tabLabel) {
52
+ setHashQueryParam(TAB_QUERY_PARAM_NAME, tabLabel)
53
+ }
54
+ }
55
+
56
+ return [currentTab, setCurrentTab]
57
+ }
@@ -0,0 +1 @@
1
+ {"modelVersion":2,"piskel":{"name":"apple-juice","description":"","fps":12,"height":24,"width":24,"layers":["{\"name\":\"Layer 1\",\"opacity\":1,\"frameCount\":1,\"chunks\":[{\"layout\":[[0]],\"base64PNG\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAQJJREFUSIljYBgFo4CBgYHhxo0b/2/cuPGfLhbRxGAWFpb/MHzjxo3/yHwWFha8ljISY/i+nRPhfDFpZ4ZXT/eiqHFyz2f48+cPQbPwuhwbPrR3KgqN1QxCllw81sDALaCBIf71ww0Gfat8BmTfYQNMhCzAZjhM/OKxBgYn93zKLMAGnCd04bUcGRAMImRQmFLLcEmHl2FvQRnReoj2wY28OQyRj74ztG97xXDKrYS6FtzIm8Pw6cYNFLFVDnnUswDdcAYGBgYFNjbqWUAJINuCB79+Uc8Cs109GIaHHZhElAV4yw8WFpb/uHIyAzw3NzAwMDDgLIuIKuwIqSGroBsyAADMgGh66tN3lAAAAABJRU5ErkJggg==\"}]}"]}}
Binary file
@@ -0,0 +1 @@
1
+ {"modelVersion":2,"piskel":{"name":"grape-syrup","description":"","fps":12,"height":24,"width":24,"layers":["{\"name\":\"Layer 1\",\"opacity\":1,\"frameCount\":1,\"chunks\":[{\"layout\":[[0]],\"base64PNG\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAUtJREFUSIljYBjqgJFYhSmBBv+R+XPWXyBKLwuxhvuavmVQMSliuHOmDypq8J8YS5iIsYASQHQQ1bpLoARR884XROslCtx/8Oj/wiTd/wuTdP/ff/DoPxFaGBjoEURERTIMqBVuIdkConxw/8Gj/y8+I0LlxLGTDMQGE8GIQjccBh5cPsVgYWXOoKggh9cMvJK4DCfFEooiWUHXjKAanJF8/8Gj/zUGPQxXvp9mYGBgYNDhNGXw+PCQQeXyJBR1RxRyGe4/ePQfly/wpiKY4TD2FXYGBh3bXgYGBgYGjw8PGXYIyDMwCMgz2OAxA6ut6K7HBnQ4TVHkN9xciTUucMYBPsOJkSdoAbXAwFkg/V+JaEPwqR24+uD+g0f/s9UqGZ4y3sNrgPR/JYapt9px5maCRUW2WiUDAwMDhkWwYMFnOEELYJbgkydU2AEARkyHfSdIwAwAAAAASUVORK5CYII=\"}]}"]}}
Binary file
package/src/img/index.ts CHANGED
@@ -9,6 +9,7 @@ import rainbowMulchedPlot from './plot-states/rainbow-mulched-plot.png'
9
9
  import shoveledPlot from './plot-states/shoveled-plot.png'
10
10
 
11
11
  // Recipes
12
+ import appleJuice from './dishes/apple-juice.png'
12
13
  import applePie from './dishes/apple-pie.png'
13
14
  import bananaBread from './dishes/banana-bread.png'
14
15
  import bananaCreamPie from './dishes/banana-cream-pie.png'
@@ -24,6 +25,7 @@ import garlicFries from './dishes/garlic-fries.png'
24
25
  import flour from './dishes/flour.png'
25
26
  import frenchOnionSoup from './dishes/french-onion-soup.png'
26
27
  import friedTofu from './dishes/fried-tofu.png'
28
+ import grapeSyrup from './dishes/grape-syrup.png'
27
29
  import jackolantern from './items/jackolantern.png'
28
30
  import hotSauce from './dishes/hot-sauce.png'
29
31
  import rainbowCheese from './dishes/rainbow-cheese.png'
@@ -327,6 +329,8 @@ export const craftedItems = {
327
329
  'olive-oil': oliveOil,
328
330
  'onion-rings': onionRings,
329
331
  popcorn,
332
+ 'apple-juice': appleJuice,
333
+ 'grape-syrup': grapeSyrup,
330
334
  'pumpkin-pie': pumpkinPie,
331
335
  rainbowCheese,
332
336
  'rainbow-mulch': rainbowMulch,
@@ -0,0 +1 @@
1
+ {"modelVersion":2,"piskel":{"name":"floorboard","description":"","fps":12,"height":48,"width":144,"layers":["{\"name\":\"Layer 1\",\"opacity\":1,\"frameCount\":1,\"chunks\":[{\"layout\":[[0]],\"base64PNG\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJAAAAAwCAYAAAD+WvNWAAAAAXNSR0IArs4c6QAAATxJREFUeJzt3bFKA0EUBdCNDFZ2iliIkFI7G5v8jt/il/gp2tjYGbAJBAsJSZcqjbaaxMYbMrubc8qpHjMX3gy7zAzeH++/GvZuNh0351fXtcuIHdUugG4TICJlNh3XruFg9WHuy/HJ2a+B1XLerI/BX7QwIgJERICIlNVyvjG4bQy22dhE025tO+RoYUQEiIgAEdm6iabd2rRm5eJmVLsGfvh8e266tCZaGBEBIiJARASIiAARGTw9jPwTzb+V08th7RoO0uJj0vRh7rUwIgJERICICBARASJSFh+T2jUcrD7MfRne3tWugT2bvL40u1p3LYyIABERICICRMTHVCJOYZXt8kRUgxZGRICICBARASIiQEQc44m4H6jl2nYf0DotjIgAEREgIgJExAVTHdDmNSp9eHKoi/ry3NM3VSVEUv4Dzh4AAAAASUVORK5CYII=\"}]}"]}}
Binary file
package/src/setupTests.ts CHANGED
@@ -52,4 +52,10 @@ beforeEach(() => {
52
52
 
53
53
  afterEach(() => {
54
54
  cleanup()
55
+ // Farmhand mirrors navigation state (current view, tabbed screens' active
56
+ // tab) into the real window.location.hash (see src/utils/hashQueryParams.ts),
57
+ // which - unlike React/component state - isn't reset between tests on its
58
+ // own and would otherwise leak into whichever test runs next in the same
59
+ // file.
60
+ window.history.replaceState({}, '', window.location.pathname)
55
61
  })
@@ -0,0 +1,89 @@
1
+ import { waitFor, within } from '@testing-library/react'
2
+
3
+ import { saveDataStubFactory } from '../test-utils/stubs/saveDataStubFactory.js'
4
+ import { farmhandStub } from '../test-utils/stubs/farmhandStub.js'
5
+
6
+ describe('restoring stageFocus from the URL hash on boot', () => {
7
+ const getHeader = () => {
8
+ const [header] = Array.from(document.getElementsByTagName('header'))
9
+
10
+ return header
11
+ }
12
+
13
+ test('restores a view named in the URL hash when a fresh game has it unlocked', async () => {
14
+ window.history.replaceState(
15
+ {},
16
+ '',
17
+ `${window.location.pathname}#?view=FIELD`
18
+ )
19
+
20
+ await farmhandStub()
21
+
22
+ await waitFor(() => {
23
+ expect(within(getHeader()).getByText('Field')).toBeInTheDocument()
24
+ })
25
+ })
26
+
27
+ test('falls back to Home when a fresh game does not have the hash view unlocked', async () => {
28
+ window.history.replaceState(
29
+ {},
30
+ '',
31
+ `${window.location.pathname}#?view=FOREST`
32
+ )
33
+
34
+ await farmhandStub()
35
+
36
+ await waitFor(() => {
37
+ expect(within(getHeader()).getByText('Home')).toBeInTheDocument()
38
+ })
39
+ expect(within(getHeader()).queryByText('Forest')).not.toBeInTheDocument()
40
+ })
41
+
42
+ test('falls back to Home when the loaded save does not have the hash view unlocked', async () => {
43
+ window.history.replaceState(
44
+ {},
45
+ '',
46
+ `${window.location.pathname}#?view=FOREST`
47
+ )
48
+
49
+ const loadedState = saveDataStubFactory({
50
+ experience: 0,
51
+ purchasedForest: 0,
52
+ })
53
+
54
+ await farmhandStub({
55
+ localforage: {
56
+ getItem: () => Promise.resolve(loadedState),
57
+ setItem: (_key: string, data: unknown) => Promise.resolve(data),
58
+ },
59
+ })
60
+
61
+ await waitFor(() => {
62
+ expect(within(getHeader()).getByText('Home')).toBeInTheDocument()
63
+ })
64
+ expect(within(getHeader()).queryByText('Forest')).not.toBeInTheDocument()
65
+ })
66
+
67
+ test('restores the hash view when the loaded save does have it unlocked', async () => {
68
+ window.history.replaceState(
69
+ {},
70
+ '',
71
+ `${window.location.pathname}#?view=FOREST`
72
+ )
73
+
74
+ const loadedState = saveDataStubFactory({
75
+ experience: 20_000,
76
+ })
77
+
78
+ await farmhandStub({
79
+ localforage: {
80
+ getItem: () => Promise.resolve(loadedState),
81
+ setItem: (_key: string, data: unknown) => Promise.resolve(data),
82
+ },
83
+ })
84
+
85
+ await waitFor(() => {
86
+ expect(within(getHeader()).getByText('Forest')).toBeInTheDocument()
87
+ })
88
+ })
89
+ })
@@ -13,7 +13,7 @@ describe('navigation', () => {
13
13
  await nextView()
14
14
  expect(within(header).getByText(viewName)).toBeInTheDocument()
15
15
  }
16
- })
16
+ }, 10000)
17
17
 
18
18
  test('cycles backwards through the standard views', async () => {
19
19
  await farmhandStub()
@@ -23,7 +23,7 @@ describe('navigation', () => {
23
23
  await previousView()
24
24
  expect(within(header).getByText(viewName)).toBeInTheDocument()
25
25
  }
26
- })
26
+ }, 10000)
27
27
 
28
28
  test('cycles forwards through the unlocked views', async () => {
29
29
  const loadedState = saveDataStubFactory({
@@ -43,7 +43,7 @@ describe('navigation', () => {
43
43
  await nextView()
44
44
  expect(within(header).getByText(viewName)).toBeInTheDocument()
45
45
  }
46
- })
46
+ }, 10000)
47
47
 
48
48
  test('cycles backwards through the unlocked views', async () => {
49
49
  const loadedState = saveDataStubFactory({
@@ -63,5 +63,5 @@ describe('navigation', () => {
63
63
  await previousView()
64
64
  expect(within(header).getByText(viewName)).toBeInTheDocument()
65
65
  }
66
- })
66
+ }, 10000)
67
67
  })
package/src/templates.ts CHANGED
@@ -310,6 +310,26 @@ export const FERMENTED_CROP_NAME = (_: any, item: farmhand.item): string =>
310
310
  export const KEG_SPOILED_MESSAGE = (_: any, keg: farmhand.keg): string =>
311
311
  `Oh no! Your ${FERMENTED_CROP_NAME(_, itemsMap[keg.itemId])} has spoiled!`
312
312
 
313
+ export const KEGS_READY_TO_SELL = (
314
+ _: any,
315
+ kegsReady: Record<string, number>
316
+ ): string => {
317
+ let message = `Kegs are ready to sell in the cellar:
318
+ `
319
+
320
+ Object.keys(kegsReady)
321
+ .sort()
322
+ .forEach(
323
+ name =>
324
+ (message += ` - ${kegsReady[name]} ${name}${
325
+ kegsReady[name] > 1 ? 's' : ''
326
+ }
327
+ `)
328
+ )
329
+
330
+ return message
331
+ }
332
+
313
333
  export const NEW_COW_OFFERED_FOR_TRADE = (
314
334
  _: any,
315
335
  peerMetadata: farmhand.peerMetadata
@@ -11,4 +11,19 @@ describe('getGrowingPhase', () => {
11
11
 
12
12
  expect(getGrowingPhase(crop as any)).toEqual(phase)
13
13
  })
14
+
15
+ test.each([
16
+ [0, 2.5],
17
+ [4, 6.5],
18
+ [5, 7.5],
19
+ [6, 8.5],
20
+ ])(
21
+ 'it handles fractional daysWatered correctly (returns phase %s when days watered is %s for pumpkin)',
22
+ (phase, daysWatered) => {
23
+ // pumpkin cropTimeline: [3, 1, 1, 1, 1, 1]
24
+ const crop = { itemId: 'pumpkin', daysWatered }
25
+
26
+ expect(getGrowingPhase(crop as any)).toEqual(phase)
27
+ }
28
+ )
14
29
  })