@jeremyckahn/farmhand 1.20.1 → 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-dORrBwcH.js.map +1 -0
- 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/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/Shop/Shop.tsx +8 -3
- package/src/components/Workshop/Workshop.tsx +11 -3
- 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/setupTests.ts +6 -0
- package/src/shell/hash-view-restoration.test.ts +89 -0
- package/src/shell/navigation.test.ts +4 -4
- 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/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-Bx-fejeL.js +0 -844
- package/dist/assets/index-Bx-fejeL.js.map +0 -1
|
@@ -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
|
+
}
|
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
|
})
|
|
@@ -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
|
})
|
|
@@ -2,23 +2,14 @@ import { itemsMap } from '../data/maps.js'
|
|
|
2
2
|
import { LARGEST_PURCHASABLE_FIELD_SIZE } from '../constants.js'
|
|
3
3
|
|
|
4
4
|
import { memoize } from './memoize.js'
|
|
5
|
+
import { getGrowingPhaseForTimeline } from './getGrowingPhaseForTimeline.js'
|
|
5
6
|
|
|
6
7
|
export const getGrowingPhase = memoize(
|
|
7
8
|
(crop: farmhand.crop) => {
|
|
8
9
|
const { itemId, daysWatered = 0 } = crop
|
|
9
10
|
const { cropTimeline = [] } = itemsMap[itemId]
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
let phase = 0
|
|
13
|
-
|
|
14
|
-
for (let value of cropTimeline) {
|
|
15
|
-
if (daysGrowing - value <= 0) break
|
|
16
|
-
|
|
17
|
-
daysGrowing -= value
|
|
18
|
-
phase += 1
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
return phase
|
|
12
|
+
return getGrowingPhaseForTimeline(cropTimeline, daysWatered)
|
|
22
13
|
},
|
|
23
14
|
{
|
|
24
15
|
cacheSize:
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { stageFocusType } from '../enums.js'
|
|
2
|
+
|
|
3
|
+
import { getValidatedStageFocusFromHash } from './getValidatedStageFocusFromHash.js'
|
|
4
|
+
|
|
5
|
+
const { FIELD, FOREST, HOME } = stageFocusType
|
|
6
|
+
|
|
7
|
+
const defaultState = {
|
|
8
|
+
experience: 0,
|
|
9
|
+
purchasedCellar: 0,
|
|
10
|
+
purchasedCowPen: 0,
|
|
11
|
+
showHomeScreen: true,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('getValidatedStageFocusFromHash', () => {
|
|
15
|
+
test('returns undefined when the hash has no view param', () => {
|
|
16
|
+
window.history.replaceState({}, '', `${window.location.pathname}`)
|
|
17
|
+
|
|
18
|
+
expect(getValidatedStageFocusFromHash(defaultState)).toBeUndefined()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('returns the view when it is unlocked for the given state', () => {
|
|
22
|
+
window.history.replaceState(
|
|
23
|
+
{},
|
|
24
|
+
'',
|
|
25
|
+
`${window.location.pathname}#?view=FIELD`
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
expect(getValidatedStageFocusFromHash(defaultState)).toEqual(FIELD)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('returns undefined when the view is not unlocked for the given state', () => {
|
|
32
|
+
window.history.replaceState(
|
|
33
|
+
{},
|
|
34
|
+
'',
|
|
35
|
+
`${window.location.pathname}#?view=FOREST`
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
expect(getValidatedStageFocusFromHash(defaultState)).toBeUndefined()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('returns the view once its unlock requirement is met', () => {
|
|
42
|
+
window.history.replaceState(
|
|
43
|
+
{},
|
|
44
|
+
'',
|
|
45
|
+
`${window.location.pathname}#?view=FOREST`
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
expect(
|
|
49
|
+
getValidatedStageFocusFromHash({ ...defaultState, experience: 20_000 })
|
|
50
|
+
).toEqual(FOREST)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('returns undefined when the hash names a view outside stageFocusType entirely', () => {
|
|
54
|
+
window.history.replaceState(
|
|
55
|
+
{},
|
|
56
|
+
'',
|
|
57
|
+
`${window.location.pathname}#?view=NOT_A_REAL_VIEW`
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
expect(getValidatedStageFocusFromHash(defaultState)).toBeUndefined()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('returns HOME when showHomeScreen is true, matching the default view list', () => {
|
|
64
|
+
window.history.replaceState(
|
|
65
|
+
{},
|
|
66
|
+
'',
|
|
67
|
+
`${window.location.pathname}#?view=HOME`
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
expect(getValidatedStageFocusFromHash(defaultState)).toEqual(HOME)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { stageFocusType } from '../enums.js'
|
|
2
|
+
|
|
3
|
+
import { getLevelEntitlements } from './getLevelEntitlements.js'
|
|
4
|
+
import { getViewList } from './getViewList.js'
|
|
5
|
+
import {
|
|
6
|
+
getHashQueryParams,
|
|
7
|
+
VIEW_QUERY_PARAM_NAME,
|
|
8
|
+
} from './hashQueryParams.js'
|
|
9
|
+
import { levelAchieved } from './levelAchieved.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Reads the URL hash's `view` param and returns it only if it names a view
|
|
13
|
+
* that's actually unlocked for the given state - guards against a
|
|
14
|
+
* bookmarked/shared URL (or a stale browser history entry reached via
|
|
15
|
+
* back/forward) pointing at a screen the current save hasn't unlocked,
|
|
16
|
+
* since Stage.tsx renders whichever view stageFocus names with no unlock
|
|
17
|
+
* check of its own. Used both at boot and when reacting to popstate.
|
|
18
|
+
*/
|
|
19
|
+
export const getValidatedStageFocusFromHash = (
|
|
20
|
+
candidateState: Pick<
|
|
21
|
+
farmhand.state,
|
|
22
|
+
'experience' | 'purchasedCellar' | 'purchasedCowPen' | 'showHomeScreen'
|
|
23
|
+
>
|
|
24
|
+
): farmhand.stageFocusType | undefined => {
|
|
25
|
+
const stageFocusFromUrl = getHashQueryParams().get(VIEW_QUERY_PARAM_NAME)
|
|
26
|
+
|
|
27
|
+
if (!stageFocusFromUrl) return undefined
|
|
28
|
+
|
|
29
|
+
const isForestUnlockedForState = getLevelEntitlements(
|
|
30
|
+
levelAchieved(candidateState.experience)
|
|
31
|
+
).stageFocusType[stageFocusType.FOREST]
|
|
32
|
+
|
|
33
|
+
const availableViews = getViewList({
|
|
34
|
+
isForestUnlocked: isForestUnlockedForState,
|
|
35
|
+
purchasedCellar: candidateState.purchasedCellar,
|
|
36
|
+
purchasedCowPen: candidateState.purchasedCowPen,
|
|
37
|
+
showHomeScreen: candidateState.showHomeScreen,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
return availableViews.find(
|
|
41
|
+
availableView => availableView === stageFocusFromUrl
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { stageFocusType } from '../enums.js'
|
|
2
|
+
|
|
3
|
+
import { getViewList } from './getViewList.js'
|
|
4
|
+
|
|
5
|
+
const { CELLAR, COW_PEN, FIELD, HOME, SHOP, WORKSHOP, FOREST } = stageFocusType
|
|
6
|
+
|
|
7
|
+
describe('getViewList', () => {
|
|
8
|
+
test('returns the standard views plus Home and Workshop by default', () => {
|
|
9
|
+
expect(
|
|
10
|
+
getViewList({
|
|
11
|
+
isForestUnlocked: false,
|
|
12
|
+
purchasedCellar: 0,
|
|
13
|
+
purchasedCowPen: 0,
|
|
14
|
+
showHomeScreen: true,
|
|
15
|
+
})
|
|
16
|
+
).toEqual([HOME, SHOP, FIELD, WORKSHOP])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('omits Home when showHomeScreen is false', () => {
|
|
20
|
+
expect(
|
|
21
|
+
getViewList({
|
|
22
|
+
isForestUnlocked: false,
|
|
23
|
+
purchasedCellar: 0,
|
|
24
|
+
purchasedCowPen: 0,
|
|
25
|
+
showHomeScreen: false,
|
|
26
|
+
})
|
|
27
|
+
).toEqual([SHOP, FIELD, WORKSHOP])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('includes Forest only when isForestUnlocked is true', () => {
|
|
31
|
+
expect(
|
|
32
|
+
getViewList({
|
|
33
|
+
isForestUnlocked: true,
|
|
34
|
+
purchasedCellar: 0,
|
|
35
|
+
purchasedCowPen: 0,
|
|
36
|
+
showHomeScreen: true,
|
|
37
|
+
})
|
|
38
|
+
).toEqual([HOME, SHOP, FIELD, FOREST, WORKSHOP])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('includes Cow Pen only when purchasedCowPen is truthy', () => {
|
|
42
|
+
expect(
|
|
43
|
+
getViewList({
|
|
44
|
+
isForestUnlocked: false,
|
|
45
|
+
purchasedCellar: 0,
|
|
46
|
+
purchasedCowPen: 1,
|
|
47
|
+
showHomeScreen: true,
|
|
48
|
+
})
|
|
49
|
+
).toEqual([HOME, SHOP, FIELD, COW_PEN, WORKSHOP])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('includes Cellar only when purchasedCellar is truthy', () => {
|
|
53
|
+
expect(
|
|
54
|
+
getViewList({
|
|
55
|
+
isForestUnlocked: false,
|
|
56
|
+
purchasedCellar: 1,
|
|
57
|
+
purchasedCowPen: 0,
|
|
58
|
+
showHomeScreen: true,
|
|
59
|
+
})
|
|
60
|
+
).toEqual([HOME, SHOP, FIELD, WORKSHOP, CELLAR])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('includes every optional view in the correct order when all are unlocked', () => {
|
|
64
|
+
expect(
|
|
65
|
+
getViewList({
|
|
66
|
+
isForestUnlocked: true,
|
|
67
|
+
purchasedCellar: 1,
|
|
68
|
+
purchasedCowPen: 1,
|
|
69
|
+
showHomeScreen: true,
|
|
70
|
+
})
|
|
71
|
+
).toEqual([HOME, SHOP, FIELD, FOREST, COW_PEN, WORKSHOP, CELLAR])
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { STANDARD_VIEW_LIST } from '../constants.js'
|
|
2
|
+
import { stageFocusType } from '../enums.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The ordered list of views available for a given state, mirroring what's
|
|
6
|
+
* actually unlocked (Home screen preference, Forest, Cow Pen, Cellar).
|
|
7
|
+
* Pulled out as a standalone function (rather than only living inline as a
|
|
8
|
+
* useMemo in useFarmhand.ts) so it can also be used to validate an
|
|
9
|
+
* externally-supplied view (e.g. from the URL hash) against a specific
|
|
10
|
+
* state snapshot - which may not be the live, currently-rendered state,
|
|
11
|
+
* such as a save being loaded during boot or import.
|
|
12
|
+
*/
|
|
13
|
+
export const getViewList = ({
|
|
14
|
+
isForestUnlocked,
|
|
15
|
+
purchasedCellar,
|
|
16
|
+
purchasedCowPen,
|
|
17
|
+
showHomeScreen,
|
|
18
|
+
}: {
|
|
19
|
+
isForestUnlocked: boolean
|
|
20
|
+
purchasedCellar: number
|
|
21
|
+
purchasedCowPen: number
|
|
22
|
+
showHomeScreen: boolean
|
|
23
|
+
}): farmhand.stageFocusType[] => {
|
|
24
|
+
const { CELLAR, COW_PEN, HOME, WORKSHOP, FOREST } = stageFocusType
|
|
25
|
+
const list: farmhand.stageFocusType[] = [...STANDARD_VIEW_LIST]
|
|
26
|
+
|
|
27
|
+
if (showHomeScreen) {
|
|
28
|
+
list.unshift(HOME)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (isForestUnlocked) {
|
|
32
|
+
list.push(FOREST)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (purchasedCowPen) {
|
|
36
|
+
list.push(COW_PEN)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
list.push(WORKSHOP)
|
|
40
|
+
|
|
41
|
+
if (purchasedCellar) {
|
|
42
|
+
list.push(CELLAR)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return list
|
|
46
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getHashQueryParams,
|
|
3
|
+
removeHashQueryParam,
|
|
4
|
+
setHashQueryParam,
|
|
5
|
+
withCurrentHashQuery,
|
|
6
|
+
} from './hashQueryParams.js'
|
|
7
|
+
|
|
8
|
+
describe('hashQueryParams', () => {
|
|
9
|
+
describe('getHashQueryParams', () => {
|
|
10
|
+
test('returns an empty URLSearchParams when there is no hash', () => {
|
|
11
|
+
window.history.replaceState({}, '', `${window.location.pathname}`)
|
|
12
|
+
|
|
13
|
+
expect(getHashQueryParams().toString()).toEqual('')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('parses query params from the hash', () => {
|
|
17
|
+
window.history.replaceState(
|
|
18
|
+
{},
|
|
19
|
+
'',
|
|
20
|
+
`${window.location.pathname}#?view=SHOP&tab=Upgrades`
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
const params = getHashQueryParams()
|
|
24
|
+
|
|
25
|
+
expect(params.get('view')).toEqual('SHOP')
|
|
26
|
+
expect(params.get('tab')).toEqual('Upgrades')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('parses query params from a hash with a router path prefix', () => {
|
|
30
|
+
window.history.replaceState(
|
|
31
|
+
{},
|
|
32
|
+
'',
|
|
33
|
+
`${window.location.pathname}#online/my-room?view=FIELD`
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
expect(getHashQueryParams().get('view')).toEqual('FIELD')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('setHashQueryParam', () => {
|
|
41
|
+
test('adds a query param to an empty hash', () => {
|
|
42
|
+
window.history.replaceState({}, '', `${window.location.pathname}`)
|
|
43
|
+
|
|
44
|
+
setHashQueryParam('view', 'SHOP')
|
|
45
|
+
|
|
46
|
+
expect(window.location.hash).toEqual('#?view=SHOP')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('preserves the hash path prefix', () => {
|
|
50
|
+
window.history.replaceState(
|
|
51
|
+
{},
|
|
52
|
+
'',
|
|
53
|
+
`${window.location.pathname}#online/my-room`
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
setHashQueryParam('view', 'SHOP')
|
|
57
|
+
|
|
58
|
+
expect(window.location.hash).toEqual('#online/my-room?view=SHOP')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('preserves other existing query params', () => {
|
|
62
|
+
window.history.replaceState(
|
|
63
|
+
{},
|
|
64
|
+
'',
|
|
65
|
+
`${window.location.pathname}#?tab=Upgrades`
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
setHashQueryParam('view', 'SHOP')
|
|
69
|
+
|
|
70
|
+
const params = getHashQueryParams()
|
|
71
|
+
|
|
72
|
+
expect(params.get('view')).toEqual('SHOP')
|
|
73
|
+
expect(params.get('tab')).toEqual('Upgrades')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('overwrites an existing value for the same key', () => {
|
|
77
|
+
window.history.replaceState(
|
|
78
|
+
{},
|
|
79
|
+
'',
|
|
80
|
+
`${window.location.pathname}#?view=SHOP`
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
setHashQueryParam('view', 'FIELD')
|
|
84
|
+
|
|
85
|
+
expect(getHashQueryParams().get('view')).toEqual('FIELD')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('removeHashQueryParam', () => {
|
|
90
|
+
test('removes the given key, leaving other params intact', () => {
|
|
91
|
+
window.history.replaceState(
|
|
92
|
+
{},
|
|
93
|
+
'',
|
|
94
|
+
`${window.location.pathname}#?view=SHOP&tab=Upgrades`
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
removeHashQueryParam('tab')
|
|
98
|
+
|
|
99
|
+
const params = getHashQueryParams()
|
|
100
|
+
|
|
101
|
+
expect(params.get('view')).toEqual('SHOP')
|
|
102
|
+
expect(params.has('tab')).toEqual(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('drops the "?" entirely when removing the last param', () => {
|
|
106
|
+
window.history.replaceState(
|
|
107
|
+
{},
|
|
108
|
+
'',
|
|
109
|
+
`${window.location.pathname}#online/my-room?tab=Upgrades`
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
removeHashQueryParam('tab')
|
|
113
|
+
|
|
114
|
+
expect(window.location.hash).toEqual('#online/my-room')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('is a no-op when the key is not present', () => {
|
|
118
|
+
window.history.replaceState(
|
|
119
|
+
{},
|
|
120
|
+
'',
|
|
121
|
+
`${window.location.pathname}#?view=SHOP`
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
removeHashQueryParam('tab')
|
|
125
|
+
|
|
126
|
+
expect(window.location.hash).toEqual('#?view=SHOP')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe('withCurrentHashQuery', () => {
|
|
131
|
+
test('returns the path unchanged when the hash has no query params', () => {
|
|
132
|
+
window.history.replaceState(
|
|
133
|
+
{},
|
|
134
|
+
'',
|
|
135
|
+
`${window.location.pathname}#online/my-room`
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
expect(withCurrentHashQuery('/')).toEqual('/')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('appends the current hash query params to the given path', () => {
|
|
142
|
+
window.history.replaceState(
|
|
143
|
+
{},
|
|
144
|
+
'',
|
|
145
|
+
`${window.location.pathname}#?view=SHOP&tab=Upgrades`
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
expect(withCurrentHashQuery('/online/my-room')).toEqual(
|
|
149
|
+
'/online/my-room?view=SHOP&tab=Upgrades'
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
})
|