@jeremyckahn/farmhand 1.20.2 → 1.20.3

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/index.html CHANGED
@@ -75,7 +75,7 @@
75
75
  gtag('config', analyticsID)
76
76
  })()
77
77
  </script>
78
- <script type="module" crossorigin src="./assets/index-dORrBwcH.js"></script>
78
+ <script type="module" crossorigin src="./assets/index-tSySi_OA.js"></script>
79
79
  <link rel="stylesheet" crossorigin href="./assets/index-CNcrTYN-.css">
80
80
  <link rel="manifest" href="./manifest.webmanifest"></head>
81
81
  <body>
@@ -1,3 +1,3 @@
1
- if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,o)=>{const r=e||("document"in self?document.currentScript.src:"")||location.href;if(s[r])return;let d={};const l=e=>i(e,r),t={module:{uri:r},exports:d,require:l};s[r]=Promise.all(n.map(e=>t[e]||l(e))).then(e=>(o(...e),d))}}define(["./workbox-2fbc6a65"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.precacheAndRoute([{url:"index.html",revision:"1d9ded7b5561cf1732b221c246cd5037"},{url:"electron.js",revision:"b0456826f47539f6c1957c3add5eb959"},{url:"assets/workbox-window.prod.es5-Bd17z0YL.js",revision:null},{url:"assets/index-dORrBwcH.js",revision:null},{url:"assets/index-CNcrTYN-.css",revision:null},{url:"favicon.ico",revision:"445bb19d4a8fd46eb433dd43cbdce691"},{url:"logo192.png",revision:"70746d790b8990639d1390337d77d81a"},{url:"logo512.png",revision:"b044ab46b3f33f0e2d313d961bf1f654"},{url:"logoMaskable.png",revision:"23be58ccf16df9f43658aad3da30056d"},{url:"manifest.webmanifest",revision:"3ad071a00582451c9cdeb827b37b9ebe"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html")))});
1
+ if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,o)=>{const r=e||("document"in self?document.currentScript.src:"")||location.href;if(s[r])return;let d={};const l=e=>i(e,r),t={module:{uri:r},exports:d,require:l};s[r]=Promise.all(n.map(e=>t[e]||l(e))).then(e=>(o(...e),d))}}define(["./workbox-2fbc6a65"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.precacheAndRoute([{url:"index.html",revision:"5d027ca75fa72ddc050fadee8c7bab00"},{url:"electron.js",revision:"b0456826f47539f6c1957c3add5eb959"},{url:"assets/workbox-window.prod.es5-Bd17z0YL.js",revision:null},{url:"assets/index-tSySi_OA.js",revision:null},{url:"assets/index-CNcrTYN-.css",revision:null},{url:"favicon.ico",revision:"445bb19d4a8fd46eb433dd43cbdce691"},{url:"logo192.png",revision:"70746d790b8990639d1390337d77d81a"},{url:"logo512.png",revision:"b044ab46b3f33f0e2d313d961bf1f654"},{url:"logoMaskable.png",revision:"23be58ccf16df9f43658aad3da30056d"},{url:"manifest.webmanifest",revision:"3ad071a00582451c9cdeb827b37b9ebe"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html")))});
2
2
  //# sourceMappingURL=service-worker.js.map
3
3
  //# sourceMappingURL=service-worker.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"service-worker.js","sources":["../../../../../tmp/176937f380891cfcd7ad2cbe4558d1d9/service-worker.js"],"sourcesContent":["import {precacheAndRoute as workbox_precaching_precacheAndRoute} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-precaching/precacheAndRoute.mjs';\nimport {cleanupOutdatedCaches as workbox_precaching_cleanupOutdatedCaches} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-precaching/cleanupOutdatedCaches.mjs';\nimport {registerRoute as workbox_routing_registerRoute} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-routing/registerRoute.mjs';\nimport {NavigationRoute as workbox_routing_NavigationRoute} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-routing/NavigationRoute.mjs';\nimport {createHandlerBoundToURL as workbox_precaching_createHandlerBoundToURL} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-precaching/createHandlerBoundToURL.mjs';/**\n * Welcome to your Workbox-powered service worker!\n *\n * You'll need to register this file in your web app.\n * See https://goo.gl/nhQhGp\n *\n * The rest of the code is auto-generated. Please don't update this file\n * directly; instead, make changes to your Workbox build configuration\n * and re-run your build process.\n * See https://goo.gl/2aRDsh\n */\n\n\n\n\nself.addEventListener('message', (event) => {\n if (event.data && event.data.type === 'SKIP_WAITING') {\n self.skipWaiting();\n }\n});\n\n/**\n * The precacheAndRoute() method efficiently caches and responds to\n * requests for URLs in the manifest.\n * See https://goo.gl/S9QRab\n */\nworkbox_precaching_precacheAndRoute([\n {\n \"url\": \"index.html\",\n \"revision\": \"1d9ded7b5561cf1732b221c246cd5037\"\n },\n {\n \"url\": \"electron.js\",\n \"revision\": \"b0456826f47539f6c1957c3add5eb959\"\n },\n {\n \"url\": \"assets/workbox-window.prod.es5-Bd17z0YL.js\",\n \"revision\": null\n },\n {\n \"url\": \"assets/index-dORrBwcH.js\",\n \"revision\": null\n },\n {\n \"url\": \"assets/index-CNcrTYN-.css\",\n \"revision\": null\n },\n {\n \"url\": \"favicon.ico\",\n \"revision\": \"445bb19d4a8fd46eb433dd43cbdce691\"\n },\n {\n \"url\": \"logo192.png\",\n \"revision\": \"70746d790b8990639d1390337d77d81a\"\n },\n {\n \"url\": \"logo512.png\",\n \"revision\": \"b044ab46b3f33f0e2d313d961bf1f654\"\n },\n {\n \"url\": \"logoMaskable.png\",\n \"revision\": \"23be58ccf16df9f43658aad3da30056d\"\n },\n {\n \"url\": \"manifest.webmanifest\",\n \"revision\": \"3ad071a00582451c9cdeb827b37b9ebe\"\n }\n], {});\nworkbox_precaching_cleanupOutdatedCaches();workbox_routing_registerRoute(new workbox_routing_NavigationRoute(workbox_precaching_createHandlerBoundToURL(\"index.html\")));\n\n\n"],"names":["self","addEventListener","event","data","type","skipWaiting","workbox_precaching_precacheAndRoute","url","revision","workbox_precaching_cleanupOutdatedCaches","workbox_routing_registerRoute","workbox_routing_NavigationRoute","workbox_precaching_createHandlerBoundToURL"],"mappings":"inBAmBAA,KAAKC,iBAAiB,UAAYC,IAC5BA,EAAMC,MAA4B,iBAApBD,EAAMC,KAAKC,MAC3BJ,KAAKK,gBASTC,EAAAA,iBAAoC,CAClC,CACEC,IAAO,aACPC,SAAY,oCAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,6CACPC,SAAY,MAEd,CACED,IAAO,2BACPC,SAAY,MAEd,CACED,IAAO,4BACPC,SAAY,MAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,mBACPC,SAAY,oCAEd,CACED,IAAO,uBACPC,SAAY,qCAEb,CAAA,GACHC,EAAAA,wBAA2CC,EAAAA,cAA8B,IAAIC,EAAAA,gBAAgCC,0BAA2C"}
1
+ {"version":3,"file":"service-worker.js","sources":["../../../../../tmp/8b70c0006c9c68a30a0afc3bb9013cc3/service-worker.js"],"sourcesContent":["import {precacheAndRoute as workbox_precaching_precacheAndRoute} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-precaching/precacheAndRoute.mjs';\nimport {cleanupOutdatedCaches as workbox_precaching_cleanupOutdatedCaches} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-precaching/cleanupOutdatedCaches.mjs';\nimport {registerRoute as workbox_routing_registerRoute} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-routing/registerRoute.mjs';\nimport {NavigationRoute as workbox_routing_NavigationRoute} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-routing/NavigationRoute.mjs';\nimport {createHandlerBoundToURL as workbox_precaching_createHandlerBoundToURL} from '/home/runner/work/farmhand/farmhand/node_modules/workbox-precaching/createHandlerBoundToURL.mjs';/**\n * Welcome to your Workbox-powered service worker!\n *\n * You'll need to register this file in your web app.\n * See https://goo.gl/nhQhGp\n *\n * The rest of the code is auto-generated. Please don't update this file\n * directly; instead, make changes to your Workbox build configuration\n * and re-run your build process.\n * See https://goo.gl/2aRDsh\n */\n\n\n\n\nself.addEventListener('message', (event) => {\n if (event.data && event.data.type === 'SKIP_WAITING') {\n self.skipWaiting();\n }\n});\n\n/**\n * The precacheAndRoute() method efficiently caches and responds to\n * requests for URLs in the manifest.\n * See https://goo.gl/S9QRab\n */\nworkbox_precaching_precacheAndRoute([\n {\n \"url\": \"index.html\",\n \"revision\": \"5d027ca75fa72ddc050fadee8c7bab00\"\n },\n {\n \"url\": \"electron.js\",\n \"revision\": \"b0456826f47539f6c1957c3add5eb959\"\n },\n {\n \"url\": \"assets/workbox-window.prod.es5-Bd17z0YL.js\",\n \"revision\": null\n },\n {\n \"url\": \"assets/index-tSySi_OA.js\",\n \"revision\": null\n },\n {\n \"url\": \"assets/index-CNcrTYN-.css\",\n \"revision\": null\n },\n {\n \"url\": \"favicon.ico\",\n \"revision\": \"445bb19d4a8fd46eb433dd43cbdce691\"\n },\n {\n \"url\": \"logo192.png\",\n \"revision\": \"70746d790b8990639d1390337d77d81a\"\n },\n {\n \"url\": \"logo512.png\",\n \"revision\": \"b044ab46b3f33f0e2d313d961bf1f654\"\n },\n {\n \"url\": \"logoMaskable.png\",\n \"revision\": \"23be58ccf16df9f43658aad3da30056d\"\n },\n {\n \"url\": \"manifest.webmanifest\",\n \"revision\": \"3ad071a00582451c9cdeb827b37b9ebe\"\n }\n], {});\nworkbox_precaching_cleanupOutdatedCaches();workbox_routing_registerRoute(new workbox_routing_NavigationRoute(workbox_precaching_createHandlerBoundToURL(\"index.html\")));\n\n\n"],"names":["self","addEventListener","event","data","type","skipWaiting","workbox_precaching_precacheAndRoute","url","revision","workbox_precaching_cleanupOutdatedCaches","workbox_routing_registerRoute","workbox_routing_NavigationRoute","workbox_precaching_createHandlerBoundToURL"],"mappings":"inBAmBAA,KAAKC,iBAAiB,UAAYC,IAC5BA,EAAMC,MAA4B,iBAApBD,EAAMC,KAAKC,MAC3BJ,KAAKK,gBASTC,EAAAA,iBAAoC,CAClC,CACEC,IAAO,aACPC,SAAY,oCAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,6CACPC,SAAY,MAEd,CACED,IAAO,2BACPC,SAAY,MAEd,CACED,IAAO,4BACPC,SAAY,MAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,cACPC,SAAY,oCAEd,CACED,IAAO,mBACPC,SAAY,oCAEd,CACED,IAAO,uBACPC,SAAY,qCAEb,CAAA,GACHC,EAAAA,wBAA2CC,EAAAA,cAA8B,IAAIC,EAAAA,gBAAgCC,0BAA2C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jeremyckahn/farmhand",
3
- "version": "1.20.2",
3
+ "version": "1.20.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -43,4 +43,70 @@ describe('<Achievement />', () => {
43
43
  screen.getByText(new RegExp(achievementObject.rewardDescription))
44
44
  ).toBeInTheDocument()
45
45
  })
46
+
47
+ test('does not render a progress bar when the achievement has no getProgress', () => {
48
+ expect(document.querySelector('.ProgressBar')).not.toBeInTheDocument()
49
+ })
50
+ })
51
+
52
+ describe('<Achievement /> with getProgress', () => {
53
+ let achievementObject: farmhand.achievement
54
+
55
+ beforeEach(() => {
56
+ achievementObject = {
57
+ description: 'the best achievement',
58
+ id: 'achievement-1',
59
+ name: 'achievement one',
60
+ rewardDescription: 'the greatest reward',
61
+ condition: () => false,
62
+ reward: state => state,
63
+ getProgress: () => ({ currentValue: 25, goal: 100 }),
64
+ }
65
+
66
+ const farmhandContextValue = createContextData()
67
+
68
+ render(
69
+ <FarmhandContext.Provider value={farmhandContextValue}>
70
+ <Achievement
71
+ achievement={achievementObject}
72
+ completedAchievements={{}}
73
+ />
74
+ </FarmhandContext.Provider>
75
+ )
76
+ })
77
+
78
+ test('renders a progress bar', () => {
79
+ expect(document.querySelector('.ProgressBar')).toBeInTheDocument()
80
+ })
81
+ })
82
+
83
+ describe('<Achievement /> with getProgress and isComplete', () => {
84
+ let achievementObject: farmhand.achievement
85
+
86
+ beforeEach(() => {
87
+ achievementObject = {
88
+ description: 'the best achievement',
89
+ id: 'achievement-1',
90
+ name: 'achievement one',
91
+ rewardDescription: 'the greatest reward',
92
+ condition: () => false,
93
+ reward: state => state,
94
+ getProgress: () => ({ currentValue: 100, goal: 100 }),
95
+ }
96
+
97
+ const farmhandContextValue = createContextData()
98
+
99
+ render(
100
+ <FarmhandContext.Provider value={farmhandContextValue}>
101
+ <Achievement
102
+ achievement={achievementObject}
103
+ completedAchievements={{ 'achievement-1': true }}
104
+ />
105
+ </FarmhandContext.Provider>
106
+ )
107
+ })
108
+
109
+ test('does not render a progress bar once the achievement is complete', () => {
110
+ expect(document.querySelector('.ProgressBar')).not.toBeInTheDocument()
111
+ })
46
112
  })
@@ -9,35 +9,58 @@ import BeenhereIcon from '@mui/icons-material/Beenhere.js'
9
9
  import { bool, object, shape, string } from 'prop-types'
10
10
 
11
11
  import FarmhandContext from '../Farmhand/Farmhand.context.js'
12
+ import { Div } from '../Elements/index.js'
13
+ import ProgressBar from '../ProgressBar/index.js'
12
14
 
13
15
  const Achievement = ({
14
- achievement: { description, id, name, rewardDescription },
16
+ achievement,
15
17
  completedAchievements,
18
+ gameState,
16
19
 
17
- isComplete = Boolean(completedAchievements[id]),
20
+ isComplete = Boolean(completedAchievements[achievement.id]),
18
21
  }: {
19
22
  achievement: farmhand.achievement
20
23
  completedAchievements: Partial<Record<string, boolean>>
24
+ gameState: farmhand.state
21
25
  isComplete?: boolean
22
- }) => (
23
- <Card
24
- {...{ className: classNames('Achievement', { 'is-complete': isComplete }) }}
25
- sx={{
26
- '& .MuiSvgIcon-root': { color: isComplete ? '#13b747' : '#666' },
27
- }}
28
- >
29
- <CardHeader
26
+ }) => {
27
+ const { description, name, rewardDescription } = achievement
28
+ const progress = achievement.getProgress?.(gameState)
29
+
30
+ return (
31
+ <Card
30
32
  {...{
31
- avatar: isComplete ? <BeenhereIcon /> : <AssignmentLateIcon />,
32
- title: name,
33
- subheader: <p>Reward: {rewardDescription}</p>,
33
+ className: classNames('Achievement', { 'is-complete': isComplete }),
34
+ }}
35
+ sx={{
36
+ '& .MuiSvgIcon-root': { color: isComplete ? '#13b747' : '#666' },
34
37
  }}
35
- />
36
- <CardContent>
37
- <p>{description}</p>
38
- </CardContent>
39
- </Card>
40
- )
38
+ >
39
+ <CardHeader
40
+ {...{
41
+ avatar: isComplete ? <BeenhereIcon /> : <AssignmentLateIcon />,
42
+ title: name,
43
+ subheader: <p>Reward: {rewardDescription}</p>,
44
+ }}
45
+ />
46
+ <CardContent>
47
+ <p>{description}</p>
48
+ {!isComplete && progress && (
49
+ <Div sx={{ marginTop: '1em' }}>
50
+ <ProgressBar
51
+ {...{
52
+ percent: Math.min(
53
+ 100,
54
+ (progress.currentValue / progress.goal) * 100
55
+ ),
56
+ }}
57
+ />
58
+ </Div>
59
+ )}
60
+ </CardContent>
61
+ </Card>
62
+ )
63
+ }
41
64
 
42
65
  Achievement.propTypes = {
43
66
  achievement: shape({
@@ -47,6 +70,7 @@ Achievement.propTypes = {
47
70
  rewardDescription: string.isRequired,
48
71
  }).isRequired,
49
72
  completedAchievements: object.isRequired,
73
+ gameState: object.isRequired,
50
74
  isComplete: bool,
51
75
  }
52
76
 
@@ -58,7 +82,7 @@ export default function Consumer(props: {
58
82
  return (
59
83
  <FarmhandContext.Consumer>
60
84
  {({ gameState, handlers }) => (
61
- <Achievement {...{ ...gameState, ...handlers, ...props }} />
85
+ <Achievement {...{ ...gameState, ...handlers, ...props, gameState }} />
62
86
  )}
63
87
  </FarmhandContext.Consumer>
64
88
  )
@@ -79,12 +79,21 @@ const ProgressBar = ({ percent }: { percent: number }) => {
79
79
  }, [percent, prefersReducedMotion])
80
80
 
81
81
  return (
82
- <Div className="ProgressBar" sx={{ margin: '0 auto' }}>
82
+ <Div
83
+ className="ProgressBar"
84
+ sx={{
85
+ margin: '0 auto',
86
+ display: 'flex',
87
+ alignItems: 'center',
88
+ gap: '0.5em',
89
+ }}
90
+ >
83
91
  <Div
84
92
  className="progress-wrapper"
85
93
  sx={{
94
+ flexGrow: 1,
86
95
  height: '1em',
87
- background: '#ddd',
96
+ background: 'rgba(0, 0, 0, 0.15)',
88
97
  borderRadius: '0.5em',
89
98
  overflow: 'hidden',
90
99
  }}
@@ -100,7 +109,7 @@ const ProgressBar = ({ percent }: { percent: number }) => {
100
109
  sx={{ height: '100%' }}
101
110
  ></Div>
102
111
  </Div>
103
- <P sx={{ lineHeight: '2em', textAlign: 'center' }}>
112
+ <P sx={{ lineHeight: '1em', minWidth: '3em', textAlign: 'right' }}>
104
113
  <span>{displayedProgress}%</span>
105
114
  </P>
106
115
  </Div>
@@ -5,9 +5,32 @@ import { toolLevel, toolType } from '../enums.js'
5
5
 
6
6
  import { INFINITE_STORAGE_LIMIT } from '../constants.js'
7
7
 
8
- import { achievementsMap } from './achievements.js'
8
+ import { achievementsMap, progressAchievement } from './achievements.js'
9
9
  import { carrot } from './crops/index.js'
10
10
 
11
+ describe('progressAchievement', () => {
12
+ test('condition is false and getProgress reflects currentValue below goal', () => {
13
+ const { condition, getProgress } = progressAchievement(10, () => 9)
14
+
15
+ expect(condition({} as any)).toEqual(false)
16
+ expect(getProgress?.({} as any)).toEqual({ currentValue: 9, goal: 10 })
17
+ })
18
+
19
+ test('condition is true and getProgress reflects currentValue at goal', () => {
20
+ const { condition, getProgress } = progressAchievement(10, () => 10)
21
+
22
+ expect(condition({} as any)).toEqual(true)
23
+ expect(getProgress?.({} as any)).toEqual({ currentValue: 10, goal: 10 })
24
+ })
25
+
26
+ test('condition is true and getProgress reflects currentValue above goal', () => {
27
+ const { condition, getProgress } = progressAchievement(10, () => 11)
28
+
29
+ expect(condition({} as any)).toEqual(true)
30
+ expect(getProgress?.({} as any)).toEqual({ currentValue: 11, goal: 10 })
31
+ })
32
+ })
33
+
11
34
  describe('harvest-crop', () => {
12
35
  describe('condition', () => {
13
36
  let inputState: any
@@ -85,6 +108,18 @@ describe.each(iAmRichVariants)(
85
108
 
86
109
  expect(achievement.condition(state)).toEqual(false)
87
110
  })
111
+
112
+ test('reports progress toward the revenue goal', () => {
113
+ const achievement = achievementsMap[id]
114
+ const state = {
115
+ revenue: Number(goal) - 1,
116
+ } as any
117
+
118
+ expect(achievement.getProgress?.(state)).toEqual({
119
+ currentValue: Number(goal) - 1,
120
+ goal: Number(goal),
121
+ })
122
+ })
88
123
  })
89
124
  }
90
125
  )
@@ -173,6 +208,13 @@ describe('orchardist', () => {
173
208
 
174
209
  expect(saplings).toEqual({ id: 'apple-sapling', quantity: 50 })
175
210
  })
211
+
212
+ test('reports progress toward the fruits-picked goal', () => {
213
+ expect(achievement.getProgress?.(state)).toEqual({
214
+ currentValue: 999,
215
+ goal: 1000,
216
+ })
217
+ })
176
218
  })
177
219
 
178
220
  describe('piemaker', () => {
@@ -218,6 +260,13 @@ describe('piemaker', () => {
218
260
 
219
261
  expect(state.money).toEqual(100_000)
220
262
  })
263
+
264
+ test('reports progress toward the pies-made goal', () => {
265
+ expect(achievement.getProgress?.(state)).toEqual({
266
+ currentValue: 99,
267
+ goal: 100,
268
+ })
269
+ })
221
270
  })
222
271
 
223
272
  describe('deforestation', () => {
@@ -251,6 +300,13 @@ describe('deforestation', () => {
251
300
 
252
301
  expect(wood).toEqual({ id: 'wood', quantity: 500 })
253
302
  })
303
+
304
+ test('reports progress toward the trees-chopped goal', () => {
305
+ expect(achievement.getProgress?.(state)).toEqual({
306
+ currentValue: 249,
307
+ goal: 250,
308
+ })
309
+ })
254
310
  })
255
311
 
256
312
  describe('landscaper', () => {
@@ -290,4 +346,11 @@ describe('landscaper', () => {
290
346
 
291
347
  expect(rainbowMulch).toEqual({ id: 'rainbow-mulch', quantity: 25 })
292
348
  })
349
+
350
+ test('reports progress toward the mulch-applied goal', () => {
351
+ expect(achievement.getProgress?.(state)).toEqual({
352
+ currentValue: 99,
353
+ goal: 100,
354
+ })
355
+ })
293
356
  })
@@ -48,11 +48,24 @@ const PIE_RECIPE_IDS = Object.values(recipesMap)
48
48
  .filter(recipe => recipe.isPie)
49
49
  .map(recipe => recipe.id)
50
50
 
51
- const sumOfPiesMade = (recipesMade: Partial<Record<string, number>>) =>
52
- PIE_RECIPE_IDS.reduce((sum, id) => sum + (recipesMade[id] || 0), 0)
51
+ const sumOfPiesMade = memoize(
52
+ (recipesMade: Partial<Record<string, number>>) =>
53
+ PIE_RECIPE_IDS.reduce((sum, id) => sum + (recipesMade[id] || 0), 0),
54
+ {}
55
+ )
53
56
 
54
57
  const cowFeed = itemsMap[COW_FEED_ITEM_ID]
55
58
 
59
+ // Derives condition and getProgress from a single source of truth so that an
60
+ // achievement's "complete" state and its progress bar can never disagree.
61
+ export const progressAchievement = (
62
+ goal: number,
63
+ getCurrentValue: (state: farmhand.state) => number
64
+ ): Pick<farmhand.achievement, 'condition' | 'getProgress'> => ({
65
+ condition: state => getCurrentValue(state) >= goal,
66
+ getProgress: state => ({ currentValue: getCurrentValue(state), goal }),
67
+ })
68
+
56
69
  const achievements: farmhand.achievement[] = [
57
70
  ((reward = 100) => ({
58
71
  id: 'plant-crop',
@@ -112,7 +125,7 @@ const achievements: farmhand.achievement[] = [
112
125
  goal
113
126
  )}. Proven farmers get access to the Crop Price Guide, an invaluable tool for making better buying and selling decisions!`,
114
127
  rewardDescription: 'Crop Price Guide',
115
- condition: state => state.revenue >= goal,
128
+ ...progressAchievement(goal, state => state.revenue),
116
129
  // Reward is a no-op for this achievement. The value of
117
130
  // state.completedAchievements['unlock-crop-price-guide'] (which is
118
131
  // automatically set to `true` in the achievement processing logic) is used
@@ -130,15 +143,18 @@ const achievements: farmhand.achievement[] = [
130
143
  reward: state => addItemToInventory(state, cowFeed, reward, true),
131
144
  }))(),
132
145
 
133
- ((reward = 100) => ({
146
+ ((reward = 100, goal = Object.keys(standardCowColors).length) => ({
134
147
  id: 'purchase-all-cow-colors',
135
148
  name: 'Cows of Many Colors',
136
149
  description: 'Show that you love all cows and purchase one of every color.',
137
150
  rewardDescription: `${reward} units of ${cowFeed.name}`,
138
- condition: state =>
139
- Object.values(standardCowColors).every(
140
- color => (state.cowColorsPurchased[color] || 0) > 0
141
- ),
151
+ ...progressAchievement(
152
+ goal,
153
+ state =>
154
+ Object.values(standardCowColors).filter(
155
+ color => (state.cowColorsPurchased[color] || 0) > 0
156
+ ).length
157
+ ),
142
158
  reward: state => addItemToInventory(state, cowFeed, reward, true),
143
159
  }))(),
144
160
 
@@ -159,7 +175,7 @@ const achievements: farmhand.achievement[] = [
159
175
  itemsMap['jackolantern'].name
160
176
  }. That's enough to fill a whole pumpkin patch!`,
161
177
  rewardDescription: `${reward} units of ${itemsMap['scarecrow'].name}`,
162
- condition: state => (state.itemsSold.jackolantern || 0) >= goal,
178
+ ...progressAchievement(goal, state => state.itemsSold.jackolantern || 0),
163
179
  reward: state =>
164
180
  addItemToInventory(state, itemsMap['scarecrow'], reward, true),
165
181
  }))(),
@@ -169,12 +185,13 @@ const achievements: farmhand.achievement[] = [
169
185
  name: `Daily profit: ${dollarString(goal)}`,
170
186
  description: `Earn ${dollarString(goal)} of profit in a single day.`,
171
187
  rewardDescription: `${reward} units of ${itemsMap['fertilizer'].name}`,
172
- condition: state =>
188
+ ...progressAchievement(goal, state =>
173
189
  getProfitRecord(
174
190
  state.recordSingleDayProfit,
175
191
  state.todaysRevenue,
176
192
  state.todaysLosses
177
- ) >= goal,
193
+ )
194
+ ),
178
195
  reward: state =>
179
196
  addItemToInventory(state, itemsMap['fertilizer'], reward, true),
180
197
  }))(),
@@ -184,12 +201,13 @@ const achievements: farmhand.achievement[] = [
184
201
  name: `Daily profit: ${dollarString(goal)}`,
185
202
  description: `Earn ${dollarString(goal)} of profit in a single day.`,
186
203
  rewardDescription: `${reward} units of ${itemsMap['onion-seed'].name}`,
187
- condition: state =>
204
+ ...progressAchievement(goal, state =>
188
205
  getProfitRecord(
189
206
  state.recordSingleDayProfit,
190
207
  state.todaysRevenue,
191
208
  state.todaysLosses
192
- ) >= goal,
209
+ )
210
+ ),
193
211
  reward: state =>
194
212
  addItemToInventory(state, itemsMap['onion-seed'], reward, true),
195
213
  }))(),
@@ -199,12 +217,13 @@ const achievements: farmhand.achievement[] = [
199
217
  name: `Daily profit: ${dollarString(goal)}`,
200
218
  description: `Earn ${dollarString(goal)} of profit in a single day.`,
201
219
  rewardDescription: `${reward} units of ${itemsMap['tomato-seed'].name}`,
202
- condition: state =>
220
+ ...progressAchievement(goal, state =>
203
221
  getProfitRecord(
204
222
  state.recordSingleDayProfit,
205
223
  state.todaysRevenue,
206
224
  state.todaysLosses
207
- ) >= goal,
225
+ )
226
+ ),
208
227
  reward: state =>
209
228
  addItemToInventory(state, itemsMap['tomato-seed'], reward, true),
210
229
  }))(),
@@ -214,7 +233,7 @@ const achievements: farmhand.achievement[] = [
214
233
  name: `7-day profit average: ${dollarString(goal)}`,
215
234
  description: `Reach a 7-day profit average of ${dollarString(goal)}.`,
216
235
  rewardDescription: `${reward} units of ${rewardItem.name}`,
217
- condition: state => state.record7dayProfitAverage >= goal,
236
+ ...progressAchievement(goal, state => state.record7dayProfitAverage),
218
237
  reward: state => addItemToInventory(state, rewardItem, reward, true),
219
238
  }))(),
220
239
 
@@ -223,7 +242,7 @@ const achievements: farmhand.achievement[] = [
223
242
  name: `7-day profit average: ${dollarString(goal)}`,
224
243
  description: `Reach a 7-day profit average of ${dollarString(goal)}.`,
225
244
  rewardDescription: `${reward} units of ${rewardItem.name}`,
226
- condition: state => state.record7dayProfitAverage >= goal,
245
+ ...progressAchievement(goal, state => state.record7dayProfitAverage),
227
246
  reward: state => addItemToInventory(state, rewardItem, reward, true),
228
247
  }))(),
229
248
 
@@ -232,7 +251,7 @@ const achievements: farmhand.achievement[] = [
232
251
  name: `7-day profit average: ${dollarString(goal)}`,
233
252
  description: `Reach a 7-day profit average of ${dollarString(goal)}.`,
234
253
  rewardDescription: `${reward} units of ${rewardItem.name}`,
235
- condition: state => state.record7dayProfitAverage >= goal,
254
+ ...progressAchievement(goal, state => state.record7dayProfitAverage),
236
255
  reward: state => addItemToInventory(state, rewardItem, reward, true),
237
256
  }))(),
238
257
 
@@ -241,7 +260,7 @@ const achievements: farmhand.achievement[] = [
241
260
  name: `7-day profit average: ${dollarString(goal)}`,
242
261
  description: `Reach a 7-day profit average of ${dollarString(goal)}.`,
243
262
  rewardDescription: `${reward} units of ${rewardItem.name}`,
244
- condition: state => state.record7dayProfitAverage >= goal,
263
+ ...progressAchievement(goal, state => state.record7dayProfitAverage),
245
264
  reward: state => addItemToInventory(state, rewardItem, reward, true),
246
265
  }))(),
247
266
 
@@ -250,7 +269,7 @@ const achievements: farmhand.achievement[] = [
250
269
  name: `7-day profit average: ${dollarString(goal)}`,
251
270
  description: `Reach a 7-day profit average of ${dollarString(goal)}.`,
252
271
  rewardDescription: `${reward} units of ${rewardItem.name}`,
253
- condition: state => state.record7dayProfitAverage >= goal,
272
+ ...progressAchievement(goal, state => state.record7dayProfitAverage),
254
273
  reward: state => addItemToInventory(state, rewardItem, reward, true),
255
274
  }))(),
256
275
 
@@ -259,7 +278,7 @@ const achievements: farmhand.achievement[] = [
259
278
  name: `7-day profit average: ${dollarString(goal)}`,
260
279
  description: `Reach a 7-day profit average of ${dollarString(goal)}.`,
261
280
  rewardDescription: `${reward} units of ${rewardItem.name}`,
262
- condition: state => state.record7dayProfitAverage >= goal,
281
+ ...progressAchievement(goal, state => state.record7dayProfitAverage),
263
282
  reward: state => addItemToInventory(state, rewardItem, reward, true),
264
283
  }))(),
265
284
 
@@ -273,7 +292,7 @@ const achievements: farmhand.achievement[] = [
273
292
  name: `Dairy Master`,
274
293
  description: `Sell ${integerString(goal)} units of ${goalItem.name}.`,
275
294
  rewardDescription: `${integerString(reward)} ${rewardItem.name} units`,
276
- condition: state => (state.itemsSold[goalItem.id] || 0) >= goal,
295
+ ...progressAchievement(goal, state => state.itemsSold[goalItem.id] || 0),
277
296
  reward: state => addItemToInventory(state, rewardItem, reward, true),
278
297
  }))(),
279
298
 
@@ -287,7 +306,7 @@ const achievements: farmhand.achievement[] = [
287
306
  name: `A Big Average Rainbow`,
288
307
  description: `Sell ${integerString(goal)} units of ${goalItem.name}.`,
289
308
  rewardDescription: `${integerString(reward)} ${rewardItem.name} units`,
290
- condition: state => (state.itemsSold[goalItem.id] || 0) >= goal,
309
+ ...progressAchievement(goal, state => state.itemsSold[goalItem.id] || 0),
291
310
  reward: state => addItemToInventory(state, rewardItem, reward, true),
292
311
  }))(),
293
312
 
@@ -296,7 +315,7 @@ const achievements: farmhand.achievement[] = [
296
315
  name: `Burger Master`,
297
316
  description: `Sell ${integerString(goal)} ${goalItem.name} units.`,
298
317
  rewardDescription: `${integerString(reward)} additional inventory spaces`,
299
- condition: state => (state.itemsSold[goalItem.id] || 0) >= goal,
318
+ ...progressAchievement(goal, state => state.itemsSold[goalItem.id] || 0),
300
319
  reward: state => ({
301
320
  ...state,
302
321
  inventoryLimit: state.inventoryLimit + reward,
@@ -310,7 +329,7 @@ const achievements: farmhand.achievement[] = [
310
329
  rewardDescription: `All sales receive a ${percentageString(
311
330
  I_AM_RICH_BONUSES[0]
312
331
  )} bonus`,
313
- condition: state => state.revenue >= goal,
332
+ ...progressAchievement(goal, state => state.revenue),
314
333
  reward: state => state,
315
334
  }))(),
316
335
 
@@ -321,7 +340,7 @@ const achievements: farmhand.achievement[] = [
321
340
  rewardDescription: `All sales receive a ${percentageString(
322
341
  I_AM_RICH_BONUSES[1]
323
342
  )} bonus`,
324
- condition: state => state.revenue >= goal,
343
+ ...progressAchievement(goal, state => state.revenue),
325
344
  reward: state => state,
326
345
  }))(),
327
346
 
@@ -332,7 +351,7 @@ const achievements: farmhand.achievement[] = [
332
351
  rewardDescription: `All sales receive a ${percentageString(
333
352
  I_AM_RICH_BONUSES[2]
334
353
  )} bonus`,
335
- condition: state => state.revenue >= goal,
354
+ ...progressAchievement(goal, state => state.revenue),
336
355
  reward: state => state,
337
356
  }))(),
338
357
 
@@ -364,8 +383,9 @@ const achievements: farmhand.achievement[] = [
364
383
  name: 'Orchardist',
365
384
  description: `Pick ${integerString(goal)} fruits from trees in the Forest.`,
366
385
  rewardDescription: `${reward} units of ${itemsMap['apple-sapling'].name}`,
367
- condition: state =>
368
- sumOfPartialRecordValues(state.treeFruitsHarvested) >= goal,
386
+ ...progressAchievement(goal, state =>
387
+ sumOfPartialRecordValues(state.treeFruitsHarvested)
388
+ ),
369
389
  reward: state =>
370
390
  addItemToInventory(state, itemsMap['apple-sapling'], reward, true),
371
391
  }))(),
@@ -375,7 +395,7 @@ const achievements: farmhand.achievement[] = [
375
395
  name: 'Piemaker',
376
396
  description: `Make ${integerString(goal)} pies.`,
377
397
  rewardDescription: dollarString(reward),
378
- condition: state => sumOfPiesMade(state.recipesMade) >= goal,
398
+ ...progressAchievement(goal, state => sumOfPiesMade(state.recipesMade)),
379
399
  reward: state => addMoney(state, reward),
380
400
  }))(),
381
401
 
@@ -384,7 +404,7 @@ const achievements: farmhand.achievement[] = [
384
404
  name: 'Deforestation',
385
405
  description: `Chop down ${integerString(goal)} trees in the Forest.`,
386
406
  rewardDescription: `${reward} units of ${itemsMap['wood'].name}`,
387
- condition: state => state.treesChopped >= goal,
407
+ ...progressAchievement(goal, state => state.treesChopped),
388
408
  reward: state => addItemToInventory(state, itemsMap['wood'], reward, true),
389
409
  }))(),
390
410
 
@@ -395,7 +415,9 @@ const achievements: farmhand.achievement[] = [
395
415
  goal
396
416
  )} bags of mulch (of any type) on trees in the Forest.`,
397
417
  rewardDescription: `${reward} units of ${itemsMap['rainbow-mulch'].name}`,
398
- condition: state => sumOfPartialRecordValues(state.mulchApplied) >= goal,
418
+ ...progressAchievement(goal, state =>
419
+ sumOfPartialRecordValues(state.mulchApplied)
420
+ ),
399
421
  reward: state =>
400
422
  addItemToInventory(state, itemsMap['rainbow-mulch'], reward, true),
401
423
  }))(),
@@ -780,7 +780,7 @@ export const compost = convertToRecipe({
780
780
  id: 'compost',
781
781
  name: 'Compost',
782
782
  ingredients: {
783
- [items.weed.id]: 25,
783
+ [items.weed.id]: 10,
784
784
  },
785
785
  condition: state =>
786
786
  state.purchasedComposter > 0 &&
@@ -797,7 +797,7 @@ export const fertilizer: farmhand.item = convertToRecipe({
797
797
  id: 'fertilizer',
798
798
  name: 'Fertilizer',
799
799
  ingredients: {
800
- [compost.id]: 10,
800
+ [compost.id]: 3,
801
801
  },
802
802
  condition: state =>
803
803
  state.purchasedComposter > 0 && (state.itemsSold[compost.id] || 0) >= 10,
package/src/farmhand.d.ts CHANGED
@@ -281,6 +281,12 @@ declare namespace farmhand {
281
281
 
282
282
  type achievementCondition = (state: state, prevState?: state) => boolean
283
283
  type achievementReward = (state: state) => state
284
+ type achievementProgress = (
285
+ state: state
286
+ ) => {
287
+ currentValue: number
288
+ goal: number
289
+ }
284
290
 
285
291
  interface achievement {
286
292
  id: string
@@ -289,6 +295,7 @@ declare namespace farmhand {
289
295
  rewardDescription: string
290
296
  condition: achievementCondition
291
297
  reward: achievementReward
298
+ getProgress?: achievementProgress
292
299
  }
293
300
 
294
301
  interface level {