@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.
- package/dist/assets/index-dORrBwcH.js +844 -0
- package/dist/assets/{index-VncPzOlx.js.map → index-dORrBwcH.js.map} +1 -1
- package/dist/assets/{workbox-window.prod.es5-Cch4wiA5.js → workbox-window.prod.es5-Bd17z0YL.js} +2 -2
- package/dist/assets/{workbox-window.prod.es5-Cch4wiA5.js.map → workbox-window.prod.es5-Bd17z0YL.js.map} +1 -1
- package/dist/index.html +1 -1
- package/dist/service-worker.js +1 -1
- package/dist/service-worker.js.map +1 -1
- package/package.json +3 -3
- package/src/components/Cellar/Cellar.tsx +7 -5
- package/src/components/Cellar/Keg.tsx +2 -3
- package/src/components/Farmhand/hooks/useFarmhandDayAdvancement.ts +2 -1
- package/src/components/Farmhand/hooks/useFarmhandNetwork.ts +2 -1
- package/src/components/Farmhand/useFarmhand.ts +103 -31
- package/src/components/NotificationSystem/NotificationSystem.test.tsx +53 -32
- package/src/components/NotificationSystem/NotificationSystem.tsx +20 -9
- package/src/components/Shop/Shop.tsx +8 -3
- package/src/components/Stage/Stage.tsx +9 -2
- package/src/components/Workshop/Workshop.tsx +11 -3
- package/src/data/recipes.ts +30 -0
- package/src/game-logic/reducers/processCellar.test.ts +70 -0
- package/src/game-logic/reducers/processCellar.ts +32 -2
- package/src/handlers/ui-events.tsx +8 -3
- package/src/hooks/useTabQueryParam.test.tsx +86 -0
- package/src/hooks/useTabQueryParam.ts +57 -0
- package/src/img/dishes/apple-juice.piskel +1 -0
- package/src/img/dishes/apple-juice.png +0 -0
- package/src/img/dishes/grape-syrup.piskel +1 -0
- package/src/img/dishes/grape-syrup.png +0 -0
- package/src/img/index.ts +4 -0
- package/src/img/ui/floorboard.piskel +1 -0
- package/src/img/ui/floorboard.png +0 -0
- package/src/setupTests.ts +6 -0
- package/src/shell/hash-view-restoration.test.ts +89 -0
- package/src/shell/navigation.test.ts +4 -4
- package/src/templates.ts +20 -0
- package/src/utils/getGrowingPhase.test.ts +15 -0
- package/src/utils/getGrowingPhase.ts +2 -11
- package/src/utils/getGrowingPhaseForTimeline.ts +1 -1
- package/src/utils/getKegDisplayName.test.ts +17 -0
- package/src/utils/getKegDisplayName.ts +10 -0
- package/src/utils/getValidatedStageFocusFromHash.test.ts +72 -0
- package/src/utils/getValidatedStageFocusFromHash.ts +43 -0
- package/src/utils/getViewList.test.ts +73 -0
- package/src/utils/getViewList.ts +46 -0
- package/src/utils/hashQueryParams.test.ts +153 -0
- package/src/utils/hashQueryParams.ts +72 -0
- 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]:
|
|
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
|
|
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
|
package/src/data/recipes.ts
CHANGED
|
@@ -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
|
|
38
|
+
daysUntilMature,
|
|
18
39
|
}
|
|
19
40
|
}
|
|
20
41
|
|
|
21
|
-
|
|
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:
|
|
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:
|
|
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
|
})
|