@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
@@ -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
- let daysGrowing = daysWatered + 1
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:
@@ -2,7 +2,7 @@ export const getGrowingPhaseForTimeline = (
2
2
  timeline: number[],
3
3
  daysElapsed: number
4
4
  ): number => {
5
- let daysGrowing = daysElapsed + 1
5
+ let daysGrowing = Math.floor(daysElapsed) + 1
6
6
  let phase = 0
7
7
 
8
8
  for (const value of timeline) {
@@ -0,0 +1,17 @@
1
+ import { carrot } from '../data/crops/index.js'
2
+ import { wineChardonnay } from '../data/recipes.js'
3
+ import { FERMENTED_CROP_NAME } from '../templates.js'
4
+
5
+ import { getKegDisplayName } from './getKegDisplayName.js'
6
+
7
+ describe('getKegDisplayName', () => {
8
+ test('returns the item name for a wine recipe', () => {
9
+ expect(getKegDisplayName(wineChardonnay)).toEqual(wineChardonnay.name)
10
+ })
11
+
12
+ test('returns the fermented crop name for a generic fermentable item', () => {
13
+ expect(getKegDisplayName(carrot as farmhand.item)).toEqual(
14
+ FERMENTED_CROP_NAME('', carrot as farmhand.item)
15
+ )
16
+ })
17
+ })
@@ -0,0 +1,10 @@
1
+ import { wineService } from '../services/wine.js'
2
+ import { FERMENTED_CROP_NAME } from '../templates.js'
3
+
4
+ export const getKegDisplayName = (item: farmhand.item): string => {
5
+ if (wineService.isWineRecipe(item)) {
6
+ return item.name
7
+ }
8
+
9
+ return FERMENTED_CROP_NAME('', item)
10
+ }
@@ -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
+ })
@@ -0,0 +1,72 @@
1
+ import globalWindow from 'global/window.js'
2
+
3
+ export const VIEW_QUERY_PARAM_NAME = 'view'
4
+
5
+ // The app is served behind a HashRouter (see src/index.tsx), which owns
6
+ // everything after "#" for its own path-based routing (e.g.
7
+ // "#online/my-room"). These helpers append/read a query string on that
8
+ // same hash, after any router path, so persisted UI state (e.g. the
9
+ // current view) round-trips through a reload without going through
10
+ // react-router's history/location - which requires a <Router> ancestor
11
+ // that isn't present in every test/render context this is used from.
12
+ const splitHash = () => {
13
+ const hash = globalWindow.location.hash.replace(/^#/, '')
14
+ const queryIndex = hash.indexOf('?')
15
+
16
+ return queryIndex === -1
17
+ ? { path: hash, query: '' }
18
+ : { path: hash.slice(0, queryIndex), query: hash.slice(queryIndex + 1) }
19
+ }
20
+
21
+ export type HashHistoryMode = 'push' | 'replace'
22
+
23
+ const writeHashQueryParams = (
24
+ params: URLSearchParams,
25
+ mode: HashHistoryMode = 'replace'
26
+ ): void => {
27
+ const { path } = splitHash()
28
+ const queryString = params.toString()
29
+ const newHash = `${path}${queryString ? `?${queryString}` : ''}`
30
+ const { origin, pathname, search } = globalWindow.location
31
+ const url = `${origin}${pathname}${search}#${newHash}`
32
+
33
+ if (mode === 'push') {
34
+ globalWindow.history.pushState({}, '', url)
35
+ } else {
36
+ globalWindow.history.replaceState({}, '', url)
37
+ }
38
+ }
39
+
40
+ export const getHashQueryParams = (): URLSearchParams =>
41
+ new URLSearchParams(splitHash().query)
42
+
43
+ export const setHashQueryParam = (
44
+ key: string,
45
+ value: string,
46
+ mode: HashHistoryMode = 'replace'
47
+ ): void => {
48
+ const params = getHashQueryParams()
49
+
50
+ params.set(key, value)
51
+ writeHashQueryParams(params, mode)
52
+ }
53
+
54
+ export const removeHashQueryParam = (
55
+ key: string,
56
+ mode: HashHistoryMode = 'replace'
57
+ ): void => {
58
+ const params = getHashQueryParams()
59
+
60
+ params.delete(key)
61
+ writeHashQueryParams(params, mode)
62
+ }
63
+
64
+ // react-router's <Redirect to={path}> replaces the whole hash with `path`,
65
+ // dropping whatever query params (view/tab) were already on it - this
66
+ // carries them over onto a new redirect target (e.g. going online/offline,
67
+ // switching rooms) so navigating doesn't lose your place.
68
+ export const withCurrentHashQuery = (path: string): string => {
69
+ const queryString = getHashQueryParams().toString()
70
+
71
+ return queryString ? `${path}?${queryString}` : path
72
+ }