Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Seth Posner 2023-04-04 14:50:25 -07:00
commit d7f6caffd3
40 changed files with 3920 additions and 3428 deletions

View file

@ -6,6 +6,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **BREAKING** New requirements system
- Replaces many features' existing requirements with new generic form
- Formulas, which can be used to calculate buy max for you
- Action feature
- ETA util
- createCollapsibleMilestones util
- deleteLowerSaves util
- Minimized layers can now display a component
- submitOnBlur property to Text fields
- showPopups property to Milestones
- Mouse/touch events to more onClick listeners
- Example hotkey to starting layer
- Schema for projInfo.json
### Changes
- **BREAKING** Buyables renamed to Repeatables
- Renamed purchaseLimit to limit
- Renamed buyMax to maximize
- Added initialAmount property
- **BREAKING** Persistent refs no longer have redundancies in save object
- Requires referencing persistent refs either through a proxy or by wrapping in `noPersist()`
- **BREAKING** Visibility properties can now take booleans
- Removed showIf util
- Tweaked settings display
- setupPassiveGeneration will no longer lower the resource
- displayResource now floors resource amounts
- Tweaked modifier displays, incl showing negative modifiers in red
- Hotkeys now appear on key graphic
- Mofifier sections now accept computable strings for title and subtitle
- Updated b_e
### Fixed
- NaN detection stopped working
- Now specifically only checks persistent refs
- trackTotal would increase the total when loading the save
- PWAs wouldn't show updates
- Board feature no longer working at all
- Some discord links didn't open in new tab
- Adjacent grid cells wouldn't merge
- When fixing old saves, the modVersion would not be updated
- Default layer would display `Dev Speed: 0x` when paused
- Fixed hotkeys not working with shift + numbers
- Fixed console errors about deleted persistent refs not being included in the layer object
- Modifiers wouldn't display small numbers
- Conversions' addSoftcap wouldn't affect currentAt or nextAt
- MainDisplay not respecting style and classes props
- Tabs could sometimes not update correctly
- offlineTime not capping properly
- Tooltips being user-selectable
- Workflows not working with submodules
- Various minor typing issues
### Documented
- requirements.tsx
- formulas.tsx
- repeatables.tsx
### Tests
- requirements
- formulas
Contributors: thepaperpilot, escapee, adsaf, ducdat
## [0.5.2] - 2022-08-22
### Added
- onLoad event

196
package-lock.json generated
View file

@ -44,7 +44,7 @@
"jsdom": "^20.0.0",
"prettier": "^2.5.1",
"typescript": "^4.7.4",
"vitest": "^0.28.5",
"vitest": "^0.29.3",
"vue-tsc": "^0.38.1"
},
"engines": {
@ -2718,23 +2718,23 @@
}
},
"node_modules/@vitest/expect": {
"version": "0.28.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.28.5.tgz",
"integrity": "sha512-gqTZwoUTwepwGIatnw4UKpQfnoyV0Z9Czn9+Lo2/jLIt4/AXLTn+oVZxlQ7Ng8bzcNkR+3DqLJ08kNr8jRmdNQ==",
"version": "0.29.3",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.29.3.tgz",
"integrity": "sha512-z/0JqBqqrdtrT/wzxNrWC76EpkOHdl+SvuNGxWulLaoluygntYyG5wJul5u/rQs5875zfFz/F+JaDf90SkLUIg==",
"dev": true,
"dependencies": {
"@vitest/spy": "0.28.5",
"@vitest/utils": "0.28.5",
"@vitest/spy": "0.29.3",
"@vitest/utils": "0.29.3",
"chai": "^4.3.7"
}
},
"node_modules/@vitest/runner": {
"version": "0.28.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.28.5.tgz",
"integrity": "sha512-NKkHtLB+FGjpp5KmneQjTcPLWPTDfB7ie+MmF1PnUBf/tGe2OjGxWyB62ySYZ25EYp9krR5Bw0YPLS/VWh1QiA==",
"version": "0.29.3",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.29.3.tgz",
"integrity": "sha512-XLi8ctbvOWhUWmuvBUSIBf8POEDH4zCh6bOuVxm/KGfARpgmVF1ku+vVNvyq85va+7qXxtl+MFmzyXQ2xzhAvw==",
"dev": true,
"dependencies": {
"@vitest/utils": "0.28.5",
"@vitest/utils": "0.29.3",
"p-limit": "^4.0.0",
"pathe": "^1.1.0"
}
@ -2767,24 +2767,23 @@
}
},
"node_modules/@vitest/spy": {
"version": "0.28.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.28.5.tgz",
"integrity": "sha512-7if6rsHQr9zbmvxN7h+gGh2L9eIIErgf8nSKYDlg07HHimCxp4H6I/X/DPXktVPPLQfiZ1Cw2cbDIx9fSqDjGw==",
"version": "0.29.3",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.29.3.tgz",
"integrity": "sha512-LLpCb1oOCOZcBm0/Oxbr1DQTuKLRBsSIHyLYof7z4QVE8/v8NcZKdORjMUq645fcfX55+nLXwU/1AQ+c2rND+w==",
"dev": true,
"dependencies": {
"tinyspy": "^1.0.2"
}
},
"node_modules/@vitest/utils": {
"version": "0.28.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.28.5.tgz",
"integrity": "sha512-UyZdYwdULlOa4LTUSwZ+Paz7nBHGTT72jKwdFSV4IjHF1xsokp+CabMdhjvVhYwkLfO88ylJT46YMilnkSARZA==",
"version": "0.29.3",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.29.3.tgz",
"integrity": "sha512-hg4Ff8AM1GtUnLpUJlNMxrf9f4lZr/xRJjh3uJ0QFP+vjaW82HAxKrmeBmLnhc8Os2eRf+f+VBu4ts7TafPPkA==",
"dev": true,
"dependencies": {
"cli-truncate": "^3.1.0",
"diff": "^5.1.0",
"loupe": "^2.3.6",
"picocolors": "^1.0.0",
"pretty-format": "^27.5.1"
}
},
@ -5819,15 +5818,15 @@
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="
},
"node_modules/mlly": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.1.0.tgz",
"integrity": "sha512-cwzBrBfwGC1gYJyfcy8TcZU1f+dbH/T+TuOhtYP2wLv/Fb51/uV7HJQfBPtEupZ2ORLRU1EKFS/QfS3eo9+kBQ==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.2.0.tgz",
"integrity": "sha512-+c7A3CV0KGdKcylsI6khWyts/CYrGTrRVo4R/I7u/cUsy0Conxa6LUhiEzVKIw14lc2L5aiO4+SeVe4TeGRKww==",
"dev": true,
"dependencies": {
"acorn": "^8.8.1",
"pathe": "^1.0.0",
"pkg-types": "^1.0.1",
"ufo": "^1.0.1"
"acorn": "^8.8.2",
"pathe": "^1.1.0",
"pkg-types": "^1.0.2",
"ufo": "^1.1.1"
}
},
"node_modules/ms": {
@ -6083,14 +6082,14 @@
}
},
"node_modules/pkg-types": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.1.tgz",
"integrity": "sha512-jHv9HB+Ho7dj6ItwppRDDl0iZRYBD0jsakHXtFgoLr+cHSF6xC+QL54sJmWxyGxOLYSHm0afhXhXcQDQqH9z8g==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.2.tgz",
"integrity": "sha512-hM58GKXOcj8WTqUXnsQyJYXdeAPbythQgEF3nTcEo+nkD49chjQ9IKm/QJy9xf6JakXptz86h7ecP2024rrLaQ==",
"dev": true,
"dependencies": {
"jsonc-parser": "^3.2.0",
"mlly": "^1.0.0",
"pathe": "^1.0.0"
"mlly": "^1.1.1",
"pathe": "^1.1.0"
}
},
"node_modules/postcss": {
@ -7019,9 +7018,9 @@
}
},
"node_modules/ufo": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.0.1.tgz",
"integrity": "sha512-boAm74ubXHY7KJQZLlXrtMz52qFvpsbOxDcZOnw/Wf+LS4Mmyu7JxmzD4tDLtUQtmZECypJ0FrCz4QIe6dvKRA==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.1.1.tgz",
"integrity": "sha512-MvlCc4GHrmZdAllBc0iUDowff36Q9Ndw/UzqmEKyrfSzokTd9ZCy1i+IIk5hrYKkjoYVQyNbrw7/F8XJ2rEwTg==",
"dev": true
},
"node_modules/unbox-primitive": {
@ -7202,9 +7201,9 @@
}
},
"node_modules/vite-node": {
"version": "0.28.5",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.28.5.tgz",
"integrity": "sha512-LmXb9saMGlrMZbXTvOveJKwMTBTNUH66c8rJnQ0ZPNX+myPEol64+szRzXtV5ORb0Hb/91yq+/D3oERoyAt6LA==",
"version": "0.29.3",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.29.3.tgz",
"integrity": "sha512-QYzYSA4Yt2IiduEjYbccfZQfxKp+T1Do8/HEpSX/G5WIECTFKJADwLs9c94aQH4o0A+UtCKU61lj1m5KvbxxQA==",
"dev": true,
"dependencies": {
"cac": "^6.7.14",
@ -7212,8 +7211,6 @@
"mlly": "^1.1.0",
"pathe": "^1.1.0",
"picocolors": "^1.0.0",
"source-map": "^0.6.1",
"source-map-support": "^0.5.21",
"vite": "^3.0.0 || ^4.0.0"
},
"bin": {
@ -7280,9 +7277,9 @@
}
},
"node_modules/vite-node/node_modules/rollup": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.15.0.tgz",
"integrity": "sha512-F9hrCAhnp5/zx/7HYmftvsNBkMfLfk/dXUh73hPSM2E3CRgap65orDNJbLetoiUFwSAk6iHPLvBrZ5iHYvzqsg==",
"version": "3.19.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.19.1.tgz",
"integrity": "sha512-lAbrdN7neYCg/8WaoWn/ckzCtz+jr70GFfYdlf50OF7387HTg+wiuiqJRFYawwSPpqfqDNYqK7smY/ks2iAudg==",
"dev": true,
"bin": {
"rollup": "dist/bin/rollup"
@ -7296,9 +7293,9 @@
}
},
"node_modules/vite-node/node_modules/vite": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.1.1.tgz",
"integrity": "sha512-LM9WWea8vsxhr782r9ntg+bhSFS06FJgCvvB0+8hf8UWtvaiDagKYWXndjfX6kGl74keHJUcpzrQliDXZlF5yg==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.1.4.tgz",
"integrity": "sha512-3knk/HsbSTKEin43zHu7jTwYWv81f8kgAL99G5NWBcA1LKvtvcVAC4JjBH1arBunO9kQka+1oGbrMKOjk4ZrBg==",
"dev": true,
"dependencies": {
"esbuild": "^0.16.14",
@ -7380,18 +7377,18 @@
}
},
"node_modules/vitest": {
"version": "0.28.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-0.28.5.tgz",
"integrity": "sha512-pyCQ+wcAOX7mKMcBNkzDwEHRGqQvHUl0XnoHR+3Pb1hytAHISgSxv9h0gUiSiYtISXUU3rMrKiKzFYDrI6ZIHA==",
"version": "0.29.3",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-0.29.3.tgz",
"integrity": "sha512-muMsbXnZsrzDGiyqf/09BKQsGeUxxlyLeLK/sFFM4EXdURPQRv8y7dco32DXaRORYP0bvyN19C835dT23mL0ow==",
"dev": true,
"dependencies": {
"@types/chai": "^4.3.4",
"@types/chai-subset": "^1.3.3",
"@types/node": "*",
"@vitest/expect": "0.28.5",
"@vitest/runner": "0.28.5",
"@vitest/spy": "0.28.5",
"@vitest/utils": "0.28.5",
"@vitest/expect": "0.29.3",
"@vitest/runner": "0.29.3",
"@vitest/spy": "0.29.3",
"@vitest/utils": "0.29.3",
"acorn": "^8.8.1",
"acorn-walk": "^8.2.0",
"cac": "^6.7.14",
@ -7407,7 +7404,7 @@
"tinypool": "^0.3.1",
"tinyspy": "^1.0.2",
"vite": "^3.0.0 || ^4.0.0",
"vite-node": "0.28.5",
"vite-node": "0.29.3",
"why-is-node-running": "^2.2.2"
},
"bin": {
@ -9914,23 +9911,23 @@
}
},
"@vitest/expect": {
"version": "0.28.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.28.5.tgz",
"integrity": "sha512-gqTZwoUTwepwGIatnw4UKpQfnoyV0Z9Czn9+Lo2/jLIt4/AXLTn+oVZxlQ7Ng8bzcNkR+3DqLJ08kNr8jRmdNQ==",
"version": "0.29.3",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.29.3.tgz",
"integrity": "sha512-z/0JqBqqrdtrT/wzxNrWC76EpkOHdl+SvuNGxWulLaoluygntYyG5wJul5u/rQs5875zfFz/F+JaDf90SkLUIg==",
"dev": true,
"requires": {
"@vitest/spy": "0.28.5",
"@vitest/utils": "0.28.5",
"@vitest/spy": "0.29.3",
"@vitest/utils": "0.29.3",
"chai": "^4.3.7"
}
},
"@vitest/runner": {
"version": "0.28.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.28.5.tgz",
"integrity": "sha512-NKkHtLB+FGjpp5KmneQjTcPLWPTDfB7ie+MmF1PnUBf/tGe2OjGxWyB62ySYZ25EYp9krR5Bw0YPLS/VWh1QiA==",
"version": "0.29.3",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.29.3.tgz",
"integrity": "sha512-XLi8ctbvOWhUWmuvBUSIBf8POEDH4zCh6bOuVxm/KGfARpgmVF1ku+vVNvyq85va+7qXxtl+MFmzyXQ2xzhAvw==",
"dev": true,
"requires": {
"@vitest/utils": "0.28.5",
"@vitest/utils": "0.29.3",
"p-limit": "^4.0.0",
"pathe": "^1.1.0"
},
@ -9953,24 +9950,23 @@
}
},
"@vitest/spy": {
"version": "0.28.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.28.5.tgz",
"integrity": "sha512-7if6rsHQr9zbmvxN7h+gGh2L9eIIErgf8nSKYDlg07HHimCxp4H6I/X/DPXktVPPLQfiZ1Cw2cbDIx9fSqDjGw==",
"version": "0.29.3",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.29.3.tgz",
"integrity": "sha512-LLpCb1oOCOZcBm0/Oxbr1DQTuKLRBsSIHyLYof7z4QVE8/v8NcZKdORjMUq645fcfX55+nLXwU/1AQ+c2rND+w==",
"dev": true,
"requires": {
"tinyspy": "^1.0.2"
}
},
"@vitest/utils": {
"version": "0.28.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.28.5.tgz",
"integrity": "sha512-UyZdYwdULlOa4LTUSwZ+Paz7nBHGTT72jKwdFSV4IjHF1xsokp+CabMdhjvVhYwkLfO88ylJT46YMilnkSARZA==",
"version": "0.29.3",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.29.3.tgz",
"integrity": "sha512-hg4Ff8AM1GtUnLpUJlNMxrf9f4lZr/xRJjh3uJ0QFP+vjaW82HAxKrmeBmLnhc8Os2eRf+f+VBu4ts7TafPPkA==",
"dev": true,
"requires": {
"cli-truncate": "^3.1.0",
"diff": "^5.1.0",
"loupe": "^2.3.6",
"picocolors": "^1.0.0",
"pretty-format": "^27.5.1"
}
},
@ -12129,15 +12125,15 @@
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="
},
"mlly": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.1.0.tgz",
"integrity": "sha512-cwzBrBfwGC1gYJyfcy8TcZU1f+dbH/T+TuOhtYP2wLv/Fb51/uV7HJQfBPtEupZ2ORLRU1EKFS/QfS3eo9+kBQ==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.2.0.tgz",
"integrity": "sha512-+c7A3CV0KGdKcylsI6khWyts/CYrGTrRVo4R/I7u/cUsy0Conxa6LUhiEzVKIw14lc2L5aiO4+SeVe4TeGRKww==",
"dev": true,
"requires": {
"acorn": "^8.8.1",
"pathe": "^1.0.0",
"pkg-types": "^1.0.1",
"ufo": "^1.0.1"
"acorn": "^8.8.2",
"pathe": "^1.1.0",
"pkg-types": "^1.0.2",
"ufo": "^1.1.1"
}
},
"ms": {
@ -12327,14 +12323,14 @@
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
},
"pkg-types": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.1.tgz",
"integrity": "sha512-jHv9HB+Ho7dj6ItwppRDDl0iZRYBD0jsakHXtFgoLr+cHSF6xC+QL54sJmWxyGxOLYSHm0afhXhXcQDQqH9z8g==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.2.tgz",
"integrity": "sha512-hM58GKXOcj8WTqUXnsQyJYXdeAPbythQgEF3nTcEo+nkD49chjQ9IKm/QJy9xf6JakXptz86h7ecP2024rrLaQ==",
"dev": true,
"requires": {
"jsonc-parser": "^3.2.0",
"mlly": "^1.0.0",
"pathe": "^1.0.0"
"mlly": "^1.1.1",
"pathe": "^1.1.0"
}
},
"postcss": {
@ -12981,9 +12977,9 @@
"dev": true
},
"ufo": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.0.1.tgz",
"integrity": "sha512-boAm74ubXHY7KJQZLlXrtMz52qFvpsbOxDcZOnw/Wf+LS4Mmyu7JxmzD4tDLtUQtmZECypJ0FrCz4QIe6dvKRA==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.1.1.tgz",
"integrity": "sha512-MvlCc4GHrmZdAllBc0iUDowff36Q9Ndw/UzqmEKyrfSzokTd9ZCy1i+IIk5hrYKkjoYVQyNbrw7/F8XJ2rEwTg==",
"dev": true
},
"unbox-primitive": {
@ -13101,9 +13097,9 @@
}
},
"vite-node": {
"version": "0.28.5",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.28.5.tgz",
"integrity": "sha512-LmXb9saMGlrMZbXTvOveJKwMTBTNUH66c8rJnQ0ZPNX+myPEol64+szRzXtV5ORb0Hb/91yq+/D3oERoyAt6LA==",
"version": "0.29.3",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.29.3.tgz",
"integrity": "sha512-QYzYSA4Yt2IiduEjYbccfZQfxKp+T1Do8/HEpSX/G5WIECTFKJADwLs9c94aQH4o0A+UtCKU61lj1m5KvbxxQA==",
"dev": true,
"requires": {
"cac": "^6.7.14",
@ -13111,8 +13107,6 @@
"mlly": "^1.1.0",
"pathe": "^1.1.0",
"picocolors": "^1.0.0",
"source-map": "^0.6.1",
"source-map-support": "^0.5.21",
"vite": "^3.0.0 || ^4.0.0"
},
"dependencies": {
@ -13154,18 +13148,18 @@
}
},
"rollup": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.15.0.tgz",
"integrity": "sha512-F9hrCAhnp5/zx/7HYmftvsNBkMfLfk/dXUh73hPSM2E3CRgap65orDNJbLetoiUFwSAk6iHPLvBrZ5iHYvzqsg==",
"version": "3.19.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.19.1.tgz",
"integrity": "sha512-lAbrdN7neYCg/8WaoWn/ckzCtz+jr70GFfYdlf50OF7387HTg+wiuiqJRFYawwSPpqfqDNYqK7smY/ks2iAudg==",
"dev": true,
"requires": {
"fsevents": "~2.3.2"
}
},
"vite": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.1.1.tgz",
"integrity": "sha512-LM9WWea8vsxhr782r9ntg+bhSFS06FJgCvvB0+8hf8UWtvaiDagKYWXndjfX6kGl74keHJUcpzrQliDXZlF5yg==",
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.1.4.tgz",
"integrity": "sha512-3knk/HsbSTKEin43zHu7jTwYWv81f8kgAL99G5NWBcA1LKvtvcVAC4JjBH1arBunO9kQka+1oGbrMKOjk4ZrBg==",
"dev": true,
"requires": {
"esbuild": "^0.16.14",
@ -13202,18 +13196,18 @@
}
},
"vitest": {
"version": "0.28.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-0.28.5.tgz",
"integrity": "sha512-pyCQ+wcAOX7mKMcBNkzDwEHRGqQvHUl0XnoHR+3Pb1hytAHISgSxv9h0gUiSiYtISXUU3rMrKiKzFYDrI6ZIHA==",
"version": "0.29.3",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-0.29.3.tgz",
"integrity": "sha512-muMsbXnZsrzDGiyqf/09BKQsGeUxxlyLeLK/sFFM4EXdURPQRv8y7dco32DXaRORYP0bvyN19C835dT23mL0ow==",
"dev": true,
"requires": {
"@types/chai": "^4.3.4",
"@types/chai-subset": "^1.3.3",
"@types/node": "*",
"@vitest/expect": "0.28.5",
"@vitest/runner": "0.28.5",
"@vitest/spy": "0.28.5",
"@vitest/utils": "0.28.5",
"@vitest/expect": "0.29.3",
"@vitest/runner": "0.29.3",
"@vitest/spy": "0.29.3",
"@vitest/utils": "0.29.3",
"acorn": "^8.8.1",
"acorn-walk": "^8.2.0",
"cac": "^6.7.14",
@ -13229,7 +13223,7 @@
"tinypool": "^0.3.1",
"tinyspy": "^1.0.2",
"vite": "^3.0.0 || ^4.0.0",
"vite-node": "0.28.5",
"vite-node": "0.29.3",
"why-is-node-running": "^2.2.2"
},
"dependencies": {

View file

@ -48,7 +48,7 @@
"jsdom": "^20.0.0",
"prettier": "^2.5.1",
"typescript": "^4.7.4",
"vitest": "^0.28.5",
"vitest": "^0.29.3",
"vue-tsc": "^0.38.1"
},
"engines": {

View file

@ -1,14 +1,15 @@
import Collapsible from "components/layout/Collapsible.vue";
import { GenericAchievement } from "features/achievements/achievement";
import type { Clickable, ClickableOptions, GenericClickable } from "features/clickables/clickable";
import { createClickable } from "features/clickables/clickable";
import type { GenericConversion } from "features/conversion";
import type { CoercableComponent, JSXFunction, OptionsFunc, Replace } from "features/feature";
import { jsx, setDefault } from "features/feature";
import { GenericMilestone } from "features/milestones/milestone";
import { displayResource, Resource } from "features/resources/resource";
import type { GenericTree, GenericTreeNode, TreeNode, TreeNodeOptions } from "features/trees/tree";
import { createTreeNode } from "features/trees/tree";
import { GenericFormula } from "game/formulas";
import Formula from "game/formulas/formulas";
import type { FormulaSource, GenericFormula } from "game/formulas/types";
import type { Modifier } from "game/modifiers";
import type { Persistent } from "game/persistence";
import { DefaultValue, persistent } from "game/persistence";
@ -383,35 +384,35 @@ export function colorText(textToColor: string, color = "var(--accent2)"): JSX.El
}
/**
* Creates a collapsible display of a list of milestones
* @param milestones A dictionary of the milestones to display, inserted in the order from easiest to hardest
* Creates a collapsible display of a list of achievements
* @param achievements A dictionary of the achievements to display, inserted in the order from easiest to hardest
*/
export function createCollapsibleMilestones(milestones: Record<string, GenericMilestone>) {
// Milestones are typically defined from easiest to hardest, and we want to show hardest first
const orderedMilestones = Object.values(milestones).reverse();
const collapseMilestones = persistent<boolean>(true, false);
const lockedMilestones = computed(() =>
orderedMilestones.filter(m => m.earned.value === false)
export function createCollapsibleAchievements(achievements: Record<string, GenericAchievement>) {
// Achievements are typically defined from easiest to hardest, and we want to show hardest first
const orderedAchievements = Object.values(achievements).reverse();
const collapseAchievements = persistent<boolean>(true, false);
const lockedAchievements = computed(() =>
orderedAchievements.filter(m => m.earned.value === false)
);
const { firstFeature, collapsedContent, hasCollapsedContent } = getFirstFeature(
orderedMilestones,
orderedAchievements,
m => m.earned.value
);
const display = jsx(() => {
const milestonesToDisplay = [...lockedMilestones.value];
const achievementsToDisplay = [...lockedAchievements.value];
if (firstFeature.value) {
milestonesToDisplay.push(firstFeature.value);
achievementsToDisplay.push(firstFeature.value);
}
return renderColJSX(
...milestonesToDisplay,
...achievementsToDisplay,
jsx(() => (
<Collapsible
collapsed={collapseMilestones}
collapsed={collapseAchievements}
content={collapsedContent}
display={
collapseMilestones.value
? "Show other completed milestones"
: "Hide other completed milestones"
collapseAchievements.value
? "Show other completed achievements"
: "Hide other completed achievements"
}
v-show={unref(hasCollapsedContent)}
/>
@ -419,7 +420,7 @@ export function createCollapsibleMilestones(milestones: Record<string, GenericMi
);
});
return {
collapseMilestones,
collapseAchievements: collapseAchievements,
display
};
}
@ -464,7 +465,7 @@ export function createFormulaPreview(
const processedShowPreview = convertComputable(showPreview);
const processedPreviewAmount = convertComputable(previewAmount);
if (!formula.hasVariable()) {
throw "Cannot create formula preview if the formula does not have a variable";
throw new Error("Cannot create formula preview if the formula does not have a variable");
}
return computed(() => {
if (unref(processedShowPreview)) {
@ -490,3 +491,24 @@ export function createFormulaPreview(
return formatSmall(formula.evaluate());
});
}
export function modifierToFormula<T extends GenericFormula>(
modifier: WithRequired<Modifier, "revert">,
base: T
): T;
export function modifierToFormula(modifier: Modifier, base: FormulaSource): GenericFormula;
export function modifierToFormula(modifier: Modifier, base: FormulaSource) {
return new Formula({
inputs: [base],
evaluate: val => modifier.apply(val),
invert:
"revert" in modifier && modifier.revert != null
? (val, lhs) => {
if (lhs instanceof Formula && lhs.hasVariable()) {
return lhs.invert(modifier.revert!(val));
}
throw new Error("Could not invert due to no input being a variable");
}
: undefined
});
}

View file

@ -3,7 +3,7 @@
* @hidden
*/
import { main } from "data/projEntry";
import { createCumulativeConversion, createPolynomialScaling } from "features/conversion";
import { createCumulativeConversion } from "features/conversion";
import { jsx } from "features/feature";
import { createHotkey } from "features/hotkey";
import { createReset } from "features/reset";
@ -23,10 +23,9 @@ const layer = createLayer(id, function (this: BaseLayer) {
const points = createResource<DecimalSource>(0, "prestige points");
const conversion = createCumulativeConversion(() => ({
scaling: createPolynomialScaling(10, 0.5),
formula: x => x.div(10).sqrt(),
baseResource: main.points,
gainResource: points,
roundUpCost: true
gainResource: points
}));
const reset = createReset(() => ({

View file

@ -12,25 +12,26 @@
feature: true,
achievement: true,
locked: !unref(earned),
bought: unref(earned),
done: unref(earned),
small: unref(small),
...unref(classes)
}"
>
<component v-if="component" :is="component" />
<component v-if="comp" :is="comp" />
<MarkNode :mark="unref(mark)" />
<Node :id="id" />
</div>
</template>
<script lang="ts">
<script lang="tsx">
import "components/common/features.css";
import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue";
import type { CoercableComponent } from "features/feature";
import { Visibility, isHidden, isVisible } from "features/feature";
import { computeOptionalComponent, processedPropType } from "util/vue";
import type { StyleValue } from "vue";
import { defineComponent, toRefs, unref } from "vue";
import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import { displayRequirements, Requirements } from "game/requirements";
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
import { Component, defineComponent, shallowRef, StyleValue, toRefs, unref, UnwrapRef, watchEffect } from "vue";
import { GenericAchievement } from "./achievement";
export default defineComponent({
props: {
@ -38,15 +39,17 @@ export default defineComponent({
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
display: processedPropType<CoercableComponent>(Object, String, Function),
display: processedPropType<UnwrapRef<GenericAchievement["display"]>>(Object, String, Function),
earned: {
type: processedPropType<boolean>(Boolean),
required: true
},
requirements: processedPropType<Requirements>(Object, Array),
image: processedPropType<string>(String),
style: processedPropType<StyleValue>(String, Object, Array),
classes: processedPropType<Record<string, boolean>>(Object),
mark: processedPropType<boolean | string>(Boolean, String),
small: processedPropType<boolean>(Boolean),
id: {
type: String,
required: true
@ -57,10 +60,46 @@ export default defineComponent({
MarkNode
},
setup(props) {
const { display } = toRefs(props);
const { display, requirements, earned } = toRefs(props);
const comp = shallowRef<Component | string>("");
watchEffect(() => {
const currDisplay = unwrapRef(display);
if (currDisplay == null) {
comp.value = "";
return;
}
if (isCoercableComponent(currDisplay)) {
comp.value = coerceComponent(currDisplay);
return;
}
const Requirement = coerceComponent(currDisplay.requirement ? currDisplay.requirement : jsx(() => displayRequirements(unwrapRef(requirements) ?? [])), "h3");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "", "b");
const OptionsDisplay = unwrapRef(earned) ?
coerceComponent(currDisplay.optionsDisplay || "", "span") :
"";
comp.value = coerceComponent(
jsx(() => (
<span>
<Requirement />
{currDisplay.effectDisplay != null ? (
<div>
<EffectDisplay />
</div>
) : null}
{currDisplay.optionsDisplay != null ? (
<div class="equal-spaced">
<OptionsDisplay />
</div>
) : null}
</span>
))
);
});
return {
component: computeOptionalComponent(display),
comp,
unref,
Visibility,
isVisible,
@ -78,4 +117,32 @@ export default defineComponent({
color: white;
text-shadow: 0 0 2px #000000;
}
.achievement:not(.small) {
height: unset;
width: calc(100% - 10px);
min-width: 120px;
padding-left: 5px;
padding-right: 5px;
background-color: var(--locked);
border-width: 4px;
border-radius: 5px;
color: rgba(0, 0, 0, 0.5);
font-size: unset;
text-shadow: unset;
}
.achievement.done {
background-color: var(--bought);
cursor: default;
}
.achievement :deep(.equal-spaced) {
display: flex;
justify-content: center;
}
.achievement :deep(.equal-spaced > *) {
margin: auto;
}
</style>

View file

@ -1,22 +1,35 @@
import { computed } from "@vue/reactivity";
import { isArray } from "@vue/shared";
import Select from "components/fields/Select.vue";
import AchievementComponent from "features/achievements/Achievement.vue";
import { Decorator } from "features/decorators/common";
import {
CoercableComponent,
Component,
GatherProps,
GenericComponent,
getUniqueID,
isVisible,
jsx,
OptionsFunc,
Replace,
setDefault,
StyleValue,
Visibility
} from "features/feature";
import { globalBus } from "game/events";
import "game/notifications";
import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence";
import player from "game/player";
import settings from "game/settings";
import {
createBooleanRequirement,
createVisibilityRequirement,
displayRequirements,
Requirements,
requirementsMet
} from "game/requirements";
import settings, { registerSettingField } from "game/settings";
import { camelToTitle } from "util/common";
import type {
Computable,
GetComputableType,
@ -25,34 +38,79 @@ import type {
} from "util/computed";
import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { coerceComponent } from "util/vue";
import { coerceComponent, isCoercableComponent } from "util/vue";
import { unref, watchEffect } from "vue";
import { useToast } from "vue-toastification";
const toast = useToast();
/** A symbol used to identify {@link Achievement} features. */
export const AchievementType = Symbol("Achievement");
/** Modes for only displaying some achievements. */
export enum AchievementDisplay {
All = "all",
//Last = "last",
Configurable = "configurable",
Incomplete = "incomplete",
None = "none"
}
/**
* An object that configures an {@link Achievement}.
*/
export interface AchievementOptions {
/** Whether this achievement should be visible. */
visibility?: Computable<Visibility | boolean>;
shouldEarn?: () => boolean;
display?: Computable<CoercableComponent>;
/** The requirement(s) to earn this achievement. Can be left null if using {@link BaseAchievement.complete}. */
requirements?: Requirements;
/** The display to use for this achievement. */
display?: Computable<
| CoercableComponent
| {
/** Description of the requirement(s) for this achievement. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
requirement?: CoercableComponent;
/** Description of what will change (if anything) for achieving this. */
effectDisplay?: CoercableComponent;
/** Any additional things to display on this achievement, such as a toggle for it's effect. */
optionsDisplay?: CoercableComponent;
}
>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>;
/** Toggles a smaller design for the feature. */
small?: Computable<boolean>;
/** An image to display as the background for this achievement. */
image?: Computable<string>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** Whether or not to display a notification popup when this achievement is earned. */
showPopups?: Computable<boolean>;
/** A function that is called when the achievement is completed. */
onComplete?: VoidFunction;
}
/**
* The properties that are added onto a processed {@link AchievementOptions} to create an {@link Achievement}.
*/
export interface BaseAchievement {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** Whether or not this achievement has been earned. */
earned: Persistent<boolean>;
/** A function to complete this achievement. */
complete: VoidFunction;
/** A symbol that helps identify features of the same type. */
type: typeof AchievementType;
[Component]: typeof AchievementComponent;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represents a feature with requirements that is passively earned upon meeting certain requirements. */
export type Achievement<T extends AchievementOptions> = Replace<
T & BaseAchievement,
{
@ -62,16 +120,23 @@ export type Achievement<T extends AchievementOptions> = Replace<
image: GetComputableType<T["image"]>;
style: GetComputableType<T["style"]>;
classes: GetComputableType<T["classes"]>;
showPopups: GetComputableTypeWithDefault<T["showPopups"], true>;
}
>;
/** A type that matches any valid {@link Achievement} object. */
export type GenericAchievement = Replace<
Achievement<AchievementOptions>,
{
visibility: ProcessedComputable<Visibility | boolean>;
showPopups: ProcessedComputable<boolean>;
}
>;
/**
* Lazily creates an achievement with the given options.
* @param optionsFunc Achievement options.
*/
export function createAchievement<T extends AchievementOptions>(
optionsFunc?: OptionsFunc<T, BaseAchievement, GenericAchievement>,
...decorators: Decorator<T, BaseAchievement, GenericAchievement>[]
@ -82,7 +147,7 @@ export function createAchievement<T extends AchievementOptions>(
const achievement = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>);
achievement.id = getUniqueID("achievement-");
achievement.type = AchievementType;
achievement[Component] = AchievementComponent;
achievement[Component] = AchievementComponent as GenericComponent;
for (const decorator of decorators) {
decorator.preConstruct?.(achievement);
@ -91,41 +156,21 @@ export function createAchievement<T extends AchievementOptions>(
achievement.earned = earned;
achievement.complete = function () {
earned.value = true;
};
Object.assign(achievement, decoratedData);
processComputable(achievement as T, "visibility");
setDefault(achievement, "visibility", Visibility.Visible);
processComputable(achievement as T, "display");
processComputable(achievement as T, "mark");
processComputable(achievement as T, "image");
processComputable(achievement as T, "style");
processComputable(achievement as T, "classes");
for (const decorator of decorators) {
decorator.postConstruct?.(achievement);
}
const decoratedProps = decorators.reduce((current, next) => Object.assign(current, next.getGatheredProps?.(achievement)), {});
achievement[GatherProps] = function (this: GenericAchievement) {
const { visibility, display, earned, image, style, classes, mark, id } = this;
return { visibility, display, earned, image, style: unref(style), classes, mark, id, ...decoratedProps };
};
if (achievement.shouldEarn) {
const genericAchievement = achievement as GenericAchievement;
watchEffect(() => {
if (settings.active !== player.id) return;
if (
!genericAchievement.earned.value &&
isVisible(genericAchievement.visibility) &&
genericAchievement.shouldEarn?.()
) {
genericAchievement.earned.value = true;
genericAchievement.onComplete?.();
if (genericAchievement.display != null) {
const Display = coerceComponent(unref(genericAchievement.display));
if (
genericAchievement.display != null &&
unref(genericAchievement.showPopups) === true
) {
const display = unref(genericAchievement.display);
let Display;
if (isCoercableComponent(display)) {
Display = coerceComponent(display);
} else if (display.requirement != null) {
Display = coerceComponent(display.requirement);
} else {
Display = displayRequirements(genericAchievement.requirements ?? []);
}
toast.info(
<div>
<h3>Achievement earned!</h3>
@ -137,6 +182,96 @@ export function createAchievement<T extends AchievementOptions>(
</div>
);
}
};
Object.assign(achievement, decoratedData);
processComputable(achievement as T, "visibility");
setDefault(achievement, "visibility", Visibility.Visible);
const visibility = achievement.visibility as ProcessedComputable<Visibility | boolean>;
achievement.visibility = computed(() => {
const display = unref((achievement as GenericAchievement).display);
switch (settings.msDisplay) {
default:
case AchievementDisplay.All:
return unref(visibility);
case AchievementDisplay.Configurable:
if (
unref(achievement.earned) &&
!(
display != null &&
typeof display == "object" &&
"optionsDisplay" in (display as Record<string, unknown>)
)
) {
return Visibility.None;
}
return unref(visibility);
case AchievementDisplay.Incomplete:
if (unref(achievement.earned)) {
return Visibility.None;
}
return unref(visibility);
case AchievementDisplay.None:
return Visibility.None;
}
});
processComputable(achievement as T, "display");
processComputable(achievement as T, "mark");
processComputable(achievement as T, "small");
processComputable(achievement as T, "image");
processComputable(achievement as T, "style");
processComputable(achievement as T, "classes");
processComputable(achievement as T, "showPopups");
setDefault(achievement, "showPopups", true);
for (const decorator of decorators) {
decorator.postConstruct?.(achievement);
}
const decoratedProps = decorators.reduce((current, next) => Object.assign(current, next.getGatheredProps?.(achievement)), {});
achievement[GatherProps] = function (this: GenericAchievement) {
const {
visibility,
display,
requirements,
earned,
image,
style,
classes,
mark,
small,
id
} = this;
return {
visibility,
display,
requirements,
earned,
image,
style: unref(style),
classes,
mark,
small,
id,
...decoratedProps
};
};
if (achievement.requirements) {
const genericAchievement = achievement as GenericAchievement;
const requirements = [
createVisibilityRequirement(genericAchievement),
createBooleanRequirement(() => !genericAchievement.earned.value),
...(isArray(achievement.requirements)
? achievement.requirements
: [achievement.requirements])
];
watchEffect(() => {
if (settings.active !== player.id) return;
if (requirementsMet(requirements)) {
genericAchievement.complete();
}
});
}
@ -144,3 +279,34 @@ export function createAchievement<T extends AchievementOptions>(
return achievement as unknown as Achievement<T>;
});
}
declare module "game/settings" {
interface Settings {
msDisplay: AchievementDisplay;
}
}
globalBus.on("loadSettings", settings => {
setDefault(settings, "msDisplay", AchievementDisplay.All);
});
const msDisplayOptions = Object.values(AchievementDisplay).map(option => ({
label: camelToTitle(option),
value: option
}));
registerSettingField(
jsx(() => (
<Select
title={jsx(() => (
<span class="option-title">
Show achievements
<desc>Select which achievements to display based on criterias.</desc>
</span>
))}
options={msDisplayOptions}
onUpdate:modelValue={value => (settings.msDisplay = value as AchievementDisplay)}
modelValue={settings.msDisplay}
/>
))
);

View file

@ -33,26 +33,46 @@ import { BarOptions, createBar, GenericBar } from "./bars/bar";
import { ClickableOptions } from "./clickables/clickable";
import { Decorator } from "./decorators/common";
/** A symbol used to identify {@link Action} features. */
export const ActionType = Symbol("Action");
/**
* An object that configures a {@link Action}.
*/
export interface ActionOptions extends Omit<ClickableOptions, "onClick" | "onHold"> {
/** The cooldown during which the action cannot be performed again, in seconds. */
duration: Computable<DecimalSource>;
/** Whether or not the action should perform automatically when the cooldown is finished. */
autoStart?: Computable<boolean>;
/** A function that is called when the action is clicked. */
onClick: (amount: DecimalSource) => void;
/** A pass-through to the {@link Bar} used to display the cooldown progress for the action. */
barOptions?: Partial<BarOptions>;
}
/**
* The properties that are added onto a processed {@link ActionOptions} to create an {@link Action}.
*/
export interface BaseAction {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** A symbol that helps identify features of the same type. */
type: typeof ActionType;
/** Whether or not the player is holding down the action. Actions will be considered clicked as soon as the cooldown completes when being held down. */
isHolding: Ref<boolean>;
/** The current amount of progress through the cooldown. */
progress: Ref<DecimalSource>;
/** The bar used to display the current cooldown progress. */
progressBar: GenericBar;
/** Update the cooldown the specified number of seconds */
update: (diff: number) => void;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represens a feature that can be clicked upon, and then have a cooldown before they can be clicked again. */
export type Action<T extends ActionOptions> = Replace<
T & BaseAction,
{
@ -68,6 +88,7 @@ export type Action<T extends ActionOptions> = Replace<
}
>;
/** A type that matches any valid {@link Action} object. */
export type GenericAction = Replace<
Action<ActionOptions>,
{
@ -77,6 +98,10 @@ export type GenericAction = Replace<
}
>;
/**
* Lazily creates an action with the given options.
* @param optionsFunc Action options.
*/
export function createAction<T extends ActionOptions>(
optionsFunc?: OptionsFunc<T, BaseAction, GenericAction>,
...decorators: Decorator<T, BaseAction, GenericAction>[]

View file

@ -20,31 +20,56 @@ import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { unref } from "vue";
/** A symbol used to identify {@link Bar} features. */
export const BarType = Symbol("Bar");
/**
* An object that configures a {@link Bar}.
*/
export interface BarOptions {
/** Whether this bar should be visible. */
visibility?: Computable<Visibility | boolean>;
/** The width of the bar. */
width: Computable<number>;
/** The height of the bar. */
height: Computable<number>;
/** The direction in which the bar progresses. */
direction: Computable<Direction>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** CSS to apply to the bar's border. */
borderStyle?: Computable<StyleValue>;
/** CSS to apply to the bar's base. */
baseStyle?: Computable<StyleValue>;
/** CSS to apply to the bar's text. */
textStyle?: Computable<StyleValue>;
/** CSS to apply to the bar's fill. */
fillStyle?: Computable<StyleValue>;
/** The progress value of the bar, from 0 to 1. */
progress: Computable<DecimalSource>;
/** The display to use for this bar. */
display?: Computable<CoercableComponent>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>;
}
/**
* The properties that are added onto a processed {@link BarOptions} to create a {@link Bar}.
*/
export interface BaseBar {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** A symbol that helps identify features of the same type. */
type: typeof BarType;
[Component]: typeof BarComponent;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represents a feature that displays some sort of progress or completion or resource with a cap. */
export type Bar<T extends BarOptions> = Replace<
T & BaseBar,
{
@ -64,6 +89,7 @@ export type Bar<T extends BarOptions> = Replace<
}
>;
/** A type that matches any valid {@link Bar} object. */
export type GenericBar = Replace<
Bar<BarOptions>,
{
@ -71,6 +97,10 @@ export type GenericBar = Replace<
}
>;
/**
* Lazily creates a bar with the given options.
* @param optionsFunc Bar options.
*/
export function createBar<T extends BarOptions>(
optionsFunc: OptionsFunc<T, BaseBar, GenericBar>,
...decorators: Decorator<T, BaseBar, GenericBar>[]
@ -80,7 +110,7 @@ export function createBar<T extends BarOptions>(
const bar = optionsFunc();
bar.id = getUniqueID("bar-");
bar.type = BarType;
bar[Component] = BarComponent;
bar[Component] = BarComponent as GenericComponent;
for (const decorator of decorators) {
decorator.preConstruct?.(bar);

View file

@ -1,5 +1,5 @@
import BoardComponent from "features/boards/Board.vue";
import type { OptionsFunc, Replace, StyleValue } from "features/feature";
import type { GenericComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import {
Component,
findFeatures,
@ -27,20 +27,27 @@ import type { Link } from "../links/links";
globalBus.on("setupVue", app => panZoom.install(app));
/** A symbol used to identify {@link Board} features. */
export const BoardType = Symbol("Board");
/**
* A type representing a computable value for a node on the board. Used for node types to return different values based on the given node and the state of the board.
*/
export type NodeComputable<T> = Computable<T> | ((node: BoardNode) => T);
/** Ways to display progress of an action with a duration. */
export enum ProgressDisplay {
Outline = "Outline",
Fill = "Fill"
}
/** Node shapes. */
export enum Shape {
Circle = "Circle",
Diamond = "Triangle"
}
/** An object representing a node on the board. */
export interface BoardNode {
id: number;
position: {
@ -52,48 +59,76 @@ export interface BoardNode {
pinned?: boolean;
}
/** An object representing a link between two nodes on the board. */
export interface BoardNodeLink extends Omit<Link, "startNode" | "endNode"> {
startNode: BoardNode;
endNode: BoardNode;
pulsing?: boolean;
}
/** An object representing a label for a node. */
export interface NodeLabel {
text: string;
color?: string;
pulsing?: boolean;
}
/** The persistent data for a board. */
export type BoardData = {
nodes: BoardNode[];
selectedNode: number | null;
selectedAction: string | null;
};
/**
* An object that configures a {@link NodeType}.
*/
export interface NodeTypeOptions {
/** The title to display for the node. */
title: NodeComputable<string>;
/** An optional label for the node. */
label?: NodeComputable<NodeLabel | null>;
/** The size of the node - diameter for circles, width and height for squares. */
size: NodeComputable<number>;
/** Whether the node is draggable or not. */
draggable?: NodeComputable<boolean>;
/** The shape of the node. */
shape: NodeComputable<Shape>;
/** Whether the node can accept another node being dropped upon it. */
canAccept?: boolean | Ref<boolean> | ((node: BoardNode, otherNode: BoardNode) => boolean);
/** The progress value of the node. */
progress?: NodeComputable<number>;
/** How the progress should be displayed on the node. */
progressDisplay?: NodeComputable<ProgressDisplay>;
/** The color of the progress indicator. */
progressColor?: NodeComputable<string>;
/** The fill color of the node. */
fillColor?: NodeComputable<string>;
/** The outline color of the node. */
outlineColor?: NodeComputable<string>;
/** The color of the title text. */
titleColor?: NodeComputable<string>;
/** The list of action options for the node. */
actions?: BoardNodeActionOptions[];
/** The distance between the center of the node and its actions. */
actionDistance?: NodeComputable<number>;
/** A function that is called when the node is clicked. */
onClick?: (node: BoardNode) => void;
/** A function that is called when a node is dropped onto this node. */
onDrop?: (node: BoardNode, otherNode: BoardNode) => void;
/** A function that is called for each node of this type every tick. */
update?: (node: BoardNode, diff: number) => void;
}
/**
* The properties that are added onto a processed {@link NodeTypeOptions} to create a {@link NodeType}.
*/
export interface BaseNodeType {
/** The nodes currently on the board of this type. */
nodes: Ref<BoardNode[]>;
}
/** An object that represents a type of node that can appear on a board. It will handle getting properties and callbacks for every node of that type. */
export type NodeType<T extends NodeTypeOptions> = Replace<
T & BaseNodeType,
{
@ -114,6 +149,7 @@ export type NodeType<T extends NodeTypeOptions> = Replace<
}
>;
/** A type that matches any valid {@link NodeType} object. */
export type GenericNodeType = Replace<
NodeType<NodeTypeOptions>,
{
@ -127,20 +163,34 @@ export type GenericNodeType = Replace<
}
>;
/**
* An object that configures a {@link BoardNodeAction}.
*/
export interface BoardNodeActionOptions {
/** A unique identifier for the action. */
id: string;
/** Whether this action should be visible. */
visibility?: NodeComputable<Visibility | boolean>;
/** The icon to display for the action. */
icon: NodeComputable<string>;
/** The fill color of the action. */
fillColor?: NodeComputable<string>;
/** The tooltip text to display for the action. */
tooltip: NodeComputable<string>;
/** An array of board node links associated with the action. They appear when the action is focused. */
links?: NodeComputable<BoardNodeLink[]>;
/** A function that is called when the action is clicked. */
onClick: (node: BoardNode) => boolean | undefined;
}
/**
* The properties that are added onto a processed {@link BoardNodeActionOptions} to create an {@link BoardNodeAction}.
*/
export interface BaseBoardNodeAction {
links?: Ref<BoardNodeLink[]>;
}
/** An object that represents an action that can be taken upon a node. */
export type BoardNodeAction<T extends BoardNodeActionOptions> = Replace<
T & BaseBoardNodeAction,
{
@ -152,6 +202,7 @@ export type BoardNodeAction<T extends BoardNodeActionOptions> = Replace<
}
>;
/** A type that matches any valid {@link BoardNodeAction} object. */
export type GenericBoardNodeAction = Replace<
BoardNodeAction<BoardNodeActionOptions>,
{
@ -159,29 +210,53 @@ export type GenericBoardNodeAction = Replace<
}
>;
/**
* An object that configures a {@link Board}.
*/
export interface BoardOptions {
/** Whether this board should be visible. */
visibility?: Computable<Visibility | boolean>;
/** The height of the board. Defaults to 100% */
height?: Computable<string>;
/** The width of the board. Defaults to 100% */
width?: Computable<string>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** A function that returns an array of initial board nodes, without IDs. */
startNodes: () => Omit<BoardNode, "id">[];
/** A dictionary of node types that can appear on the board. */
types: Record<string, NodeTypeOptions>;
/** The persistent state of the board. */
state?: Computable<BoardData>;
/** An array of board node links to display. */
links?: Computable<BoardNodeLink[] | null>;
}
/**
* The properties that are added onto a processed {@link BoardOptions} to create a {@link Board}.
*/
export interface BaseBoard {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** All the nodes currently on the board. */
nodes: Ref<BoardNode[]>;
/** The currently selected node, if any. */
selectedNode: Ref<BoardNode | null>;
/** The currently selected action, if any. */
selectedAction: Ref<GenericBoardNodeAction | null>;
/** The current mouse position, if over the board. */
mousePosition: Ref<{ x: number; y: number } | null>;
/** A symbol that helps identify features of the same type. */
type: typeof BoardType;
[Component]: typeof BoardComponent;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represents a feature that is a zoomable, pannable board with various nodes upon it. */
export type Board<T extends BoardOptions> = Replace<
T & BaseBoard,
{
@ -196,6 +271,7 @@ export type Board<T extends BoardOptions> = Replace<
}
>;
/** A type that matches any valid {@link Board} object. */
export type GenericBoard = Replace<
Board<BoardOptions>,
{
@ -205,6 +281,10 @@ export type GenericBoard = Replace<
}
>;
/**
* Lazily creates a board with the given options.
* @param optionsFunc Board options.
*/
export function createBoard<T extends BoardOptions>(
optionsFunc: OptionsFunc<T, BaseBoard, GenericBoard>
): Board<T> {
@ -221,7 +301,7 @@ export function createBoard<T extends BoardOptions>(
const board = optionsFunc();
board.id = getUniqueID("board-");
board.type = BoardType;
board[Component] = BoardComponent;
board[Component] = BoardComponent as GenericComponent;
if (board.state) {
deletePersistent(state);
@ -368,10 +448,19 @@ export function createBoard<T extends BoardOptions>(
});
}
/**
* Gets the value of a property for a specified node.
* @param property The property to find the value of
* @param node The node to get the property of
*/
export function getNodeProperty<T>(property: NodeComputable<T>, node: BoardNode): T {
return isFunction<T, [BoardNode], Computable<T>>(property) ? property(node) : unref(property);
}
/**
* Utility to get an ID for a node that is guaranteed unique.
* @param board The board feature to generate an ID for
*/
export function getUniqueNodeID(board: GenericBoard): number {
let id = 0;
board.nodes.value.forEach(node => {

View file

@ -38,6 +38,7 @@ import type { GenericChallenge } from "features/challenges/challenge";
import type { StyleValue } from "features/feature";
import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import { getHighNotifyStyle, getNotifyStyle } from "game/notifications";
import { displayRequirements, Requirements } from "game/requirements";
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
import type { Component, PropType, UnwrapRef } from "vue";
import { computed, defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
@ -61,6 +62,7 @@ export default defineComponent({
Object,
Function
),
requirements: processedPropType<Requirements>(Object, Array),
visibility: {
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
@ -90,7 +92,7 @@ export default defineComponent({
Node
},
setup(props) {
const { active, maxed, canComplete, display } = toRefs(props);
const { active, maxed, canComplete, display, requirements } = toRefs(props);
const buttonText = computed(() => {
if (active.value) {
@ -128,7 +130,7 @@ export default defineComponent({
}
const Title = coerceComponent(currDisplay.title || "", "h3");
const Description = coerceComponent(currDisplay.description, "div");
const Goal = coerceComponent(currDisplay.goal || "");
const Goal = coerceComponent(currDisplay.goal != null ? currDisplay.goal : jsx(() => displayRequirements(unwrapRef(requirements) ?? [])), "h3");
const Reward = coerceComponent(currDisplay.reward || "");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "");
comp.value = coerceComponent(
@ -140,12 +142,10 @@ export default defineComponent({
</div>
) : null}
<Description />
{currDisplay.goal != null ? (
<div>
<br />
Goal: <Goal />
</div>
) : null}
{currDisplay.reward != null ? (
<div>
<br />

View file

@ -2,7 +2,13 @@ import { isArray } from "@vue/shared";
import Toggle from "components/fields/Toggle.vue";
import ChallengeComponent from "features/challenges/Challenge.vue";
import { Decorator } from "features/decorators/common";
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import {
Component,
GatherProps,
@ -13,10 +19,10 @@ import {
Visibility
} from "features/feature";
import type { GenericReset } from "features/reset";
import type { Resource } from "features/resources/resource";
import { globalBus } from "game/events";
import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence";
import { maxRequirementsMet, Requirements } from "game/requirements";
import settings, { registerSettingField } from "game/settings";
import type { DecimalSource } from "util/bignum";
import Decimal from "util/bignum";
@ -31,73 +37,117 @@ import { createLazyProxy } from "util/proxies";
import type { Ref, WatchStopHandle } from "vue";
import { computed, unref, watch } from "vue";
export const ChallengeType = Symbol("ChallengeType");
/** A symbol used to identify {@link Challenge} features. */
export const ChallengeType = Symbol("Challenge");
/**
* An object that configures a {@link Challenge}.
*/
export interface ChallengeOptions {
/** Whether this challenge should be visible. */
visibility?: Computable<Visibility | boolean>;
/** Whether this challenge can be started. */
canStart?: Computable<boolean>;
/** The reset function for this challenge. */
reset?: GenericReset;
canComplete?: Computable<boolean | DecimalSource>;
/** The requirement(s) to complete this challenge. */
requirements: Requirements;
/** Whether or not completing this challenge should grant multiple completions if requirements met. Requires {@link requirements} to be a requirement or array of requirements with {@link Requirement.canMaximize} true. */
maximize?: Computable<boolean>;
/** The maximum number of times the challenge can be completed. */
completionLimit?: Computable<DecimalSource>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>;
resource?: Resource;
goal?: Computable<DecimalSource>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** The display to use for this challenge. */
display?: Computable<
| CoercableComponent
| {
/** A header to appear at the top of the display. */
title?: CoercableComponent;
/** The main text that appears in the display. */
description: CoercableComponent;
/** A description of the current goal for this challenge. If unspecified then the requirements will be displayed automatically based on {@link requirements}. */
goal?: CoercableComponent;
/** A description of what will change upon completing this challenge. */
reward?: CoercableComponent;
/** A description of the current effect of this challenge. */
effectDisplay?: CoercableComponent;
}
>;
/** A function that is called when the challenge is completed. */
onComplete?: VoidFunction;
/** A function that is called when the challenge is exited. */
onExit?: VoidFunction;
/** A function that is called when the challenge is entered. */
onEnter?: VoidFunction;
}
/**
* The properties that are added onto a processed {@link ChallengeOptions} to create a {@link Challenge}.
*/
export interface BaseChallenge {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** The current amount of times this challenge can be completed. */
canComplete: Ref<DecimalSource>;
/** The current number of times this challenge has been completed. */
completions: Persistent<DecimalSource>;
/** Whether or not this challenge has been completed. */
completed: Ref<boolean>;
/** Whether or not this challenge's completion count is at its limit. */
maxed: Ref<boolean>;
/** Whether or not this challenge is currently active. */
active: Persistent<boolean>;
/** A function to enter or leave the challenge. */
toggle: VoidFunction;
/**
* A function to complete this challenge.
* @param remainInChallenge - Optional parameter to specify if the challenge should remain active after completion.
*/
complete: (remainInChallenge?: boolean) => void;
/** A symbol that helps identify features of the same type. */
type: typeof ChallengeType;
[Component]: typeof ChallengeComponent;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represents a feature that can be entered and exited, and have one or more completions with scaling requirements. */
export type Challenge<T extends ChallengeOptions> = Replace<
T & BaseChallenge,
{
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
canStart: GetComputableTypeWithDefault<T["canStart"], true>;
canComplete: GetComputableTypeWithDefault<T["canComplete"], Ref<boolean>>;
requirements: GetComputableType<T["requirements"]>;
maximize: GetComputableType<T["maximize"]>;
completionLimit: GetComputableTypeWithDefault<T["completionLimit"], 1>;
mark: GetComputableTypeWithDefault<T["mark"], Ref<boolean>>;
goal: GetComputableType<T["goal"]>;
classes: GetComputableType<T["classes"]>;
style: GetComputableType<T["style"]>;
display: GetComputableType<T["display"]>;
}
>;
/** A type that matches any valid {@link Challenge} object. */
export type GenericChallenge = Replace<
Challenge<ChallengeOptions>,
{
visibility: ProcessedComputable<Visibility | boolean>;
canStart: ProcessedComputable<boolean>;
canComplete: ProcessedComputable<boolean | DecimalSource>;
completionLimit: ProcessedComputable<DecimalSource>;
mark: ProcessedComputable<boolean>;
}
>;
/**
* Lazily creates a challenge with the given options.
* @param optionsFunc Challenge options.
*/
export function createChallenge<T extends ChallengeOptions>(
optionsFunc: OptionsFunc<T, BaseChallenge, GenericChallenge>,
...decorators: Decorator<T, BaseChallenge, GenericChallenge>[]
@ -108,20 +158,9 @@ export function createChallenge<T extends ChallengeOptions>(
return createLazyProxy(() => {
const challenge = optionsFunc();
if (
challenge.canComplete == null &&
(challenge.resource == null || challenge.goal == null)
) {
console.warn(
"Cannot create challenge without a canComplete property or a resource and goal property",
challenge
);
throw "Cannot create challenge without a canComplete property or a resource and goal property";
}
challenge.id = getUniqueID("challenge-");
challenge.type = ChallengeType;
challenge[Component] = ChallengeComponent;
challenge[Component] = ChallengeComponent as GenericComponent;
for (const decorator of decorators) {
decorator.preConstruct?.(challenge);
@ -144,13 +183,10 @@ export function createChallenge<T extends ChallengeOptions>(
const genericChallenge = challenge as GenericChallenge;
if (genericChallenge.active.value) {
if (
unref(genericChallenge.canComplete) !== false &&
Decimal.gt(unref(genericChallenge.canComplete), 0) &&
!genericChallenge.maxed.value
) {
let completions: boolean | DecimalSource = unref(genericChallenge.canComplete);
if (typeof completions === "boolean") {
completions = 1;
}
const completions = unref(genericChallenge.canComplete);
genericChallenge.completions.value = Decimal.min(
Decimal.add(genericChallenge.completions.value, completions),
unref(genericChallenge.completionLimit)
@ -170,18 +206,20 @@ export function createChallenge<T extends ChallengeOptions>(
genericChallenge.onEnter?.();
}
};
challenge.canComplete = computed(() =>
Decimal.max(
maxRequirementsMet((challenge as GenericChallenge).requirements),
unref((challenge as GenericChallenge).maximize) ? Decimal.dInf : 1
)
);
challenge.complete = function (remainInChallenge?: boolean) {
const genericChallenge = challenge as GenericChallenge;
let completions: boolean | DecimalSource = unref(genericChallenge.canComplete);
const completions = unref(genericChallenge.canComplete);
if (
genericChallenge.active.value &&
completions !== false &&
(completions === true || Decimal.neq(0, completions)) &&
Decimal.gt(completions, 0) &&
!genericChallenge.maxed.value
) {
if (typeof completions === "boolean") {
completions = 1;
}
genericChallenge.completions.value = Decimal.min(
Decimal.add(genericChallenge.completions.value, completions),
unref(genericChallenge.completionLimit)
@ -203,19 +241,6 @@ export function createChallenge<T extends ChallengeOptions>(
}
return unref(visibility);
});
if (challenge.canComplete == null) {
challenge.canComplete = computed(() => {
const genericChallenge = challenge as GenericChallenge;
if (
!genericChallenge.active.value ||
genericChallenge.resource == null ||
genericChallenge.goal == null
) {
return false;
}
return Decimal.gte(genericChallenge.resource.value, unref(genericChallenge.goal));
});
}
if (challenge.mark == null) {
challenge.mark = computed(
() =>
@ -226,11 +251,10 @@ export function createChallenge<T extends ChallengeOptions>(
processComputable(challenge as T, "canStart");
setDefault(challenge, "canStart", true);
processComputable(challenge as T, "canComplete");
processComputable(challenge as T, "maximize");
processComputable(challenge as T, "completionLimit");
setDefault(challenge, "completionLimit", 1);
processComputable(challenge as T, "mark");
processComputable(challenge as T, "goal");
processComputable(challenge as T, "classes");
processComputable(challenge as T, "style");
processComputable(challenge as T, "display");
@ -261,7 +285,8 @@ export function createChallenge<T extends ChallengeOptions>(
canStart,
mark,
id,
toggle
toggle,
requirements
} = this;
return {
active,
@ -276,6 +301,7 @@ export function createChallenge<T extends ChallengeOptions>(
mark,
id,
toggle,
requirements,
...decoratedProps
};
};
@ -284,6 +310,12 @@ export function createChallenge<T extends ChallengeOptions>(
});
}
/**
* This will automatically complete a challenge when it's requirements are met.
* @param challenge The challenge to auto-complete
* @param autoActive Whether or not auto-completing should currently occur
* @param exitOnComplete Whether or not to exit the challenge after auto-completion
*/
export function setupAutoComplete(
challenge: GenericChallenge,
autoActive: Computable<boolean> = true,
@ -291,28 +323,36 @@ export function setupAutoComplete(
): WatchStopHandle {
const isActive = typeof autoActive === "function" ? computed(autoActive) : autoActive;
return watch(
[challenge.canComplete as Ref<boolean>, isActive as Ref<boolean>],
[challenge.canComplete as Ref<DecimalSource>, isActive as Ref<boolean>],
([canComplete, isActive]) => {
if (canComplete && isActive) {
if (Decimal.gt(canComplete, 0) && isActive) {
challenge.complete(!exitOnComplete);
}
}
);
}
/**
* Utility for taking an array of challenges where only one may be active at a time, and giving a ref to the one currently active (or null if none are active)
* @param challenges The list of challenges that are mutually exclusive
*/
export function createActiveChallenge(
challenges: GenericChallenge[]
): Ref<GenericChallenge | undefined> {
return computed(() => challenges.find(challenge => challenge.active.value));
): Ref<GenericChallenge | null> {
return computed(() => challenges.find(challenge => challenge.active.value) ?? null);
}
/**
* Utility for reporting if any challenge in a list is currently active. Intended for preventing entering a challenge if another is already active.
* @param challenges List of challenges that are mutually exclusive
*/
export function isAnyChallengeActive(
challenges: GenericChallenge[] | Ref<GenericChallenge | undefined>
challenges: GenericChallenge[] | Ref<GenericChallenge | null>
): Ref<boolean> {
if (isArray(challenges)) {
challenges = createActiveChallenge(challenges);
}
return computed(() => (challenges as Ref<GenericChallenge | undefined>).value != null);
return computed(() => (challenges as Ref<GenericChallenge | null>).value != null);
}
declare module "game/settings" {

View file

@ -20,33 +20,56 @@ import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { computed, unref } from "vue";
/** A symbol used to identify {@link Clickable} features. */
export const ClickableType = Symbol("Clickable");
/**
* An object that configures a {@link Clickable}.
*/
export interface ClickableOptions {
/** Whether this clickable should be visible. */
visibility?: Computable<Visibility | boolean>;
/** Whether or not the clickable may be clicked. */
canClick?: Computable<boolean>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>;
/** The display to use for this clickable. */
display?: Computable<
| CoercableComponent
| {
/** A header to appear at the top of the display. */
title?: CoercableComponent;
/** The main text that appears in the display. */
description: CoercableComponent;
}
>;
/** Toggles a smaller design for the feature. */
small?: boolean;
/** A function that is called when the clickable is clicked. */
onClick?: (e?: MouseEvent | TouchEvent) => void;
/** A function that is called when the clickable is held down. */
onHold?: VoidFunction;
}
/**
* The properties that are added onto a processed {@link ClickableOptions} to create an {@link Clickable}.
*/
export interface BaseClickable {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** A symbol that helps identify features of the same type. */
type: typeof ClickableType;
[Component]: typeof ClickableComponent;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represents a feature that can be clicked or held down. */
export type Clickable<T extends ClickableOptions> = Replace<
T & BaseClickable,
{
@ -59,6 +82,7 @@ export type Clickable<T extends ClickableOptions> = Replace<
}
>;
/** A type that matches any valid {@link Clickable} object. */
export type GenericClickable = Replace<
Clickable<ClickableOptions>,
{
@ -67,6 +91,10 @@ export type GenericClickable = Replace<
}
>;
/**
* Lazily creates a clickable with the given options.
* @param optionsFunc Clickable options.
*/
export function createClickable<T extends ClickableOptions>(
optionsFunc?: OptionsFunc<T, BaseClickable, GenericClickable>,
...decorators: Decorator<T, BaseClickable, GenericClickable>[]
@ -76,7 +104,7 @@ export function createClickable<T extends ClickableOptions>(
const clickable = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>);
clickable.id = getUniqueID("clickable-");
clickable.type = ClickableType;
clickable[Component] = ClickableComponent;
clickable[Component] = ClickableComponent as GenericComponent;
for (const decorator of decorators) {
decorator.preConstruct?.(clickable);
@ -147,6 +175,12 @@ export function createClickable<T extends ClickableOptions>(
});
}
/**
* Utility to auto click a clickable whenever it can be.
* @param layer The layer the clickable is apart of
* @param clickable The clicker to click automatically
* @param autoActive Whether or not the clickable should currently be auto-clicking
*/
export function setupAutoClick(
layer: BaseLayer,
clickable: GenericClickable,

View file

@ -1,11 +1,15 @@
import type { OptionsFunc, Replace } from "features/feature";
import { setDefault } from "features/feature";
import type { Resource } from "features/resources/resource";
import Formula from "game/formulas/formulas";
import {
IntegrableFormula,
InvertibleFormula,
InvertibleIntegralFormula
} from "game/formulas/types";
import type { BaseLayer } from "game/layers";
import type { Modifier } from "game/modifiers";
import type { DecimalSource } from "util/bignum";
import Decimal from "util/bignum";
import type { WithRequired } from "util/common";
import type { Computable, GetComputableTypeWithDefault, ProcessedComputable } from "util/computed";
import { convertComputable, processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
@ -16,9 +20,12 @@ import { Decorator } from "./decorators/common";
/** An object that configures a {@link Conversion}. */
export interface ConversionOptions {
/**
* The scaling function that is used to determine the rate of conversion from one {@link features/resources/resource.Resource} to the other.
* The formula used to determine how much {@link gainResource} should be earned by this converting.
* The passed value will be a Formula representing the {@link baseResource} variable.
*/
scaling: ScalingFunction;
formula: (
variable: InvertibleFormula & IntegrableFormula & InvertibleIntegralFormula
) => InvertibleFormula;
/**
* How much of the output resource the conversion can currently convert for.
* Typically this will be set for you in a conversion constructor.
@ -54,10 +61,6 @@ export interface ConversionOptions {
* Defaults to true.
*/
buyMax?: Computable<boolean>;
/**
* Whether or not to round up the cost to generate a given amount of the output resource.
*/
roundUpCost?: Computable<boolean>;
/**
* The function that performs the actual conversion from {@link baseResource} to {@link gainResource}.
* Typically this will be set for you in a conversion constructor.
@ -74,20 +77,6 @@ export interface ConversionOptions {
* This will not be called whenever using currentGain without calling convert (e.g. passive generation)
*/
onConvert?: (amountGained: DecimalSource) => void;
/**
* An additional modifier that will be applied to the gain amounts.
* Must be reversible in order to correctly calculate {@link nextAt}.
* @see {@link game/modifiers.createSequentialModifier} if you want to apply multiple modifiers.
*/
gainModifier?: WithRequired<Modifier, "revert">;
/**
* A modifier that will be applied to the cost amounts.
* That is to say, this modifier will be applied to the amount of baseResource before going into the scaling function.
* A cost modifier of x0.5 would give gain amounts equal to the player having half the baseResource they actually have.
* Must be reversible in order to correctly calculate {@link nextAt}.
* @see {@link game/modifiers.createSequentialModifier} if you want to apply multiple modifiers.
*/
costModifier?: WithRequired<Modifier, "revert">;
}
/**
@ -104,13 +93,13 @@ export interface BaseConversion {
export type Conversion<T extends ConversionOptions> = Replace<
T & BaseConversion,
{
formula: InvertibleFormula;
currentGain: GetComputableTypeWithDefault<T["currentGain"], Ref<DecimalSource>>;
actualGain: GetComputableTypeWithDefault<T["actualGain"], Ref<DecimalSource>>;
currentAt: GetComputableTypeWithDefault<T["currentAt"], Ref<DecimalSource>>;
nextAt: GetComputableTypeWithDefault<T["nextAt"], Ref<DecimalSource>>;
buyMax: GetComputableTypeWithDefault<T["buyMax"], true>;
spend: undefined extends T["spend"] ? (amountGained: DecimalSource) => void : T["spend"];
roundUpCost: GetComputableTypeWithDefault<T["roundUpCost"], true>;
}
>;
@ -124,7 +113,6 @@ export type GenericConversion = Replace<
nextAt: ProcessedComputable<DecimalSource>;
buyMax: ProcessedComputable<boolean>;
spend: (amountGained: DecimalSource) => void;
roundUpCost: ProcessedComputable<boolean>;
}
>;
@ -146,13 +134,14 @@ export function createConversion<T extends ConversionOptions>(
decorator.preConstruct?.(conversion);
}
(conversion as GenericConversion).formula = conversion.formula(
Formula.variable(conversion.baseResource)
);
if (conversion.currentGain == null) {
conversion.currentGain = computed(() => {
let gain = conversion.gainModifier
? conversion.gainModifier.apply(
conversion.scaling.currentGain(conversion as GenericConversion)
)
: conversion.scaling.currentGain(conversion as GenericConversion);
let gain = (conversion as GenericConversion).formula.evaluate(
conversion.baseResource.value
);
gain = Decimal.floor(gain).max(0);
if (unref(conversion.buyMax) === false) {
@ -166,17 +155,16 @@ export function createConversion<T extends ConversionOptions>(
}
if (conversion.currentAt == null) {
conversion.currentAt = computed(() => {
let current = conversion.scaling.currentAt(conversion as GenericConversion);
if (unref((conversion as GenericConversion).roundUpCost))
current = Decimal.ceil(current);
return current;
return (conversion as GenericConversion).formula.invert(
Decimal.floor(unref((conversion as GenericConversion).currentGain))
);
});
}
if (conversion.nextAt == null) {
conversion.nextAt = computed(() => {
let next = conversion.scaling.nextAt(conversion as GenericConversion);
if (unref((conversion as GenericConversion).roundUpCost)) next = Decimal.ceil(next);
return next;
return (conversion as GenericConversion).formula.invert(
Decimal.floor(unref((conversion as GenericConversion).currentGain)).add(1)
);
});
}
@ -204,8 +192,6 @@ export function createConversion<T extends ConversionOptions>(
processComputable(conversion as T, "nextAt");
processComputable(conversion as T, "buyMax");
setDefault(conversion, "buyMax", true);
processComputable(conversion as T, "roundUpCost");
setDefault(conversion, "roundUpCost", true);
for (const decorator of decorators) {
decorator.postConstruct?.(conversion);
@ -215,170 +201,6 @@ export function createConversion<T extends ConversionOptions>(
});
}
/**
* A collection of functions that allow a conversion to scale the amount of resources gained based on the input resource.
* This typically shouldn't be created directly. Instead use one of the scaling function constructors.
* @see {@link createLinearScaling}.
* @see {@link createPolynomialScaling}.
*/
export interface ScalingFunction {
/**
* Calculates the amount of the output resource a conversion should be able to currently produce.
* This should be based off of `conversion.baseResource.value`.
* The conversion is responsible for applying the gainModifier, so this function should be un-modified.
* It does not need to be clamped or rounded.
*/
currentGain: (conversion: GenericConversion) => DecimalSource;
/**
* Calculates the amount of the input resource that is required for the current value of `conversion.currentGain`.
* Note that `conversion.currentGain` has been modified by `conversion.gainModifier`, so you will need to revert that as appropriate.
* The conversion is responsible for rounding up the amount as appropriate.
* The returned value should not be below 0.
*/
currentAt: (conversion: GenericConversion) => DecimalSource;
/**
* Calculates the amount of the input resource that would be required for the current value of `conversion.currentGain` to increase.
* Note that `conversion.currentGain` has been modified by `conversion.gainModifier`, so you will need to revert that as appropriate.
* The conversion is responsible for rounding up the amount as appropriate.
* The returned value should not be below 0.
*/
nextAt: (conversion: GenericConversion) => DecimalSource;
}
/**
* Creates a scaling function based off the formula `(baseResource - base) * coefficient`.
* If the baseResource value is less than base then the currentGain will be 0.
* @param base The base variable in the scaling formula.
* @param coefficient The coefficient variable in the scaling formula.
* @example
* A scaling function created via `createLinearScaling(10, 0.5)` would produce the following values:
* | Base Resource | Current Gain |
* | ------------- | ------------ |
* | 10 | 1 |
* | 12 | 2 |
* | 20 | 6 |
*/
export function createLinearScaling(
base: Computable<DecimalSource>,
coefficient: Computable<DecimalSource>
): ScalingFunction {
const processedBase = convertComputable(base);
const processedCoefficient = convertComputable(coefficient);
return {
currentGain(conversion) {
let baseAmount: DecimalSource = unref(conversion.baseResource.value);
if (conversion.costModifier) {
baseAmount = conversion.costModifier.apply(baseAmount);
}
if (Decimal.lt(baseAmount, unref(processedBase))) {
return 0;
}
return Decimal.sub(baseAmount, unref(processedBase))
.sub(1)
.times(unref(processedCoefficient))
.add(1);
},
currentAt(conversion) {
let current: DecimalSource = unref(conversion.currentGain);
if (conversion.gainModifier) {
current = conversion.gainModifier.revert(current);
}
current = Decimal.max(0, current)
.sub(1)
.div(unref(processedCoefficient))
.add(unref(processedBase));
if (conversion.costModifier) {
current = conversion.costModifier.revert(current);
}
return current;
},
nextAt(conversion) {
let next: DecimalSource = Decimal.add(unref(conversion.currentGain), 1).floor();
if (conversion.gainModifier) {
next = conversion.gainModifier.revert(next);
}
next = Decimal.max(0, next)
.sub(1)
.div(unref(processedCoefficient))
.add(unref(processedBase))
.max(unref(processedBase));
if (conversion.costModifier) {
next = conversion.costModifier.revert(next);
}
return next;
}
};
}
/**
* Creates a scaling function based off the formula `(baseResource / base) ^ exponent`.
* If the baseResource value is less than base then the currentGain will be 0.
* @param base The base variable in the scaling formula.
* @param exponent The exponent variable in the scaling formula.
* @example
* A scaling function created via `createPolynomialScaling(10, 0.5)` would produce the following values:
* | Base Resource | Current Gain |
* | ------------- | ------------ |
* | 10 | 1 |
* | 40 | 2 |
* | 250 | 5 |
*/
export function createPolynomialScaling(
base: Computable<DecimalSource>,
exponent: Computable<DecimalSource>
): ScalingFunction {
const processedBase = convertComputable(base);
const processedExponent = convertComputable(exponent);
return {
currentGain(conversion) {
let baseAmount: DecimalSource = unref(conversion.baseResource.value);
if (conversion.costModifier) {
baseAmount = conversion.costModifier.apply(baseAmount);
}
if (Decimal.lt(baseAmount, unref(processedBase))) {
return 0;
}
const gain = Decimal.div(baseAmount, unref(processedBase)).pow(
unref(processedExponent)
);
if (gain.isNan()) {
return new Decimal(0);
}
return gain;
},
currentAt(conversion) {
let current: DecimalSource = unref(conversion.currentGain);
if (conversion.gainModifier) {
current = conversion.gainModifier.revert(current);
}
current = Decimal.max(0, current)
.root(unref(processedExponent))
.times(unref(processedBase));
if (conversion.costModifier) {
current = conversion.costModifier.revert(current);
}
return current;
},
nextAt(conversion) {
let next: DecimalSource = Decimal.add(unref(conversion.currentGain), 1).floor();
if (conversion.gainModifier) {
next = conversion.gainModifier.revert(next);
}
next = Decimal.max(0, next)
.root(unref(processedExponent))
.times(unref(processedBase))
.max(unref(processedBase));
if (conversion.costModifier) {
next = conversion.costModifier.revert(next);
}
return next;
}
};
}
/**
* Creates a conversion that simply adds to the gainResource amount upon converting.
* This is similar to the behavior of "normal" layers in The Modding Tree.
@ -406,13 +228,10 @@ export function createIndependentConversion<S extends ConversionOptions>(
if (conversion.currentGain == null) {
conversion.currentGain = computed(() => {
let gain = conversion.gainModifier
? conversion.gainModifier.apply(
conversion.scaling.currentGain(conversion as GenericConversion)
)
: conversion.scaling.currentGain(conversion as GenericConversion);
let gain = (conversion as unknown as GenericConversion).formula.evaluate(
conversion.baseResource.value
);
gain = Decimal.floor(gain).max(conversion.gainResource.value);
if (unref(conversion.buyMax) === false) {
gain = gain.min(Decimal.add(conversion.gainResource.value, 1));
}
@ -422,7 +241,9 @@ export function createIndependentConversion<S extends ConversionOptions>(
if (conversion.actualGain == null) {
conversion.actualGain = computed(() => {
let gain = Decimal.sub(
Decimal.floor(conversion.scaling.currentGain(conversion as GenericConversion)),
(conversion as unknown as GenericConversion).formula.evaluate(
conversion.baseResource.value
),
conversion.gainResource.value
).max(0);
@ -433,13 +254,11 @@ export function createIndependentConversion<S extends ConversionOptions>(
});
}
setDefault(conversion, "convert", function () {
const amountGained = unref((conversion as GenericConversion).actualGain);
conversion.gainResource.value = conversion.gainModifier
? conversion.gainModifier.apply(
unref((conversion as GenericConversion).currentGain)
)
: unref((conversion as GenericConversion).currentGain);
(conversion as GenericConversion).spend(amountGained);
const amountGained = unref((conversion as unknown as GenericConversion).actualGain);
conversion.gainResource.value = unref(
(conversion as unknown as GenericConversion).currentGain
);
(conversion as unknown as GenericConversion).spend(amountGained);
conversion.onConvert?.(amountGained);
});
@ -476,71 +295,3 @@ export function setupPassiveGeneration(
}
});
}
/**
* Given a value, this function finds the amount above a certain value and raises it to a power.
* If the power is <1, this will effectively make the value scale slower after the cap.
* @param value The raw value.
* @param cap The value after which the softcap should be applied.
* @param power The power to raise value above the cap to.
* @example
* A softcap added via `addSoftcap(scaling, 100, 0.5)` would produce the following values:
* | Raw Value | Softcapped Value |
* | --------- | ---------------- |
* | 1 | 1 |
* | 100 | 100 |
* | 125 | 105 |
* | 200 | 110 |
*/
export function softcap(
value: DecimalSource,
cap: DecimalSource,
power: DecimalSource = 0.5
): DecimalSource {
if (Decimal.lte(value, cap)) {
return value;
} else {
return Decimal.pow(value, power).times(Decimal.pow(cap, Decimal.sub(1, power)));
}
}
/**
* Creates a scaling function based off an existing scaling function, with a softcap applied to it.
* The softcap will take any value above a certain value and raise it to a power.
* If the power is <1, this will effectively make the value scale slower after the cap.
* @param scaling The raw scaling function.
* @param cap The value after which the softcap should be applied.
* @param power The power to raise value about the cap to.
* @see {@link softcap}.
*/
export function addSoftcap(
scaling: ScalingFunction,
cap: ProcessedComputable<DecimalSource>,
power: ProcessedComputable<DecimalSource> = 0.5
): ScalingFunction {
return {
...scaling,
currentAt: conversion =>
softcap(scaling.currentAt(conversion), unref(cap), Decimal.recip(unref(power))),
nextAt: conversion =>
softcap(scaling.nextAt(conversion), unref(cap), Decimal.recip(unref(power))),
currentGain: conversion =>
softcap(scaling.currentGain(conversion), unref(cap), unref(power))
};
}
/**
* Creates a scaling function off an existing function, with a hardcap applied to it.
* The harcap will ensure that the currentGain will stop at a given cap.
* @param scaling The raw scaling function.
* @param cap The maximum value the scaling function can output.
*/
export function addHardcap(
scaling: ScalingFunction,
cap: ProcessedComputable<DecimalSource>
): ScalingFunction {
return {
...scaling,
currentGain: conversion => Decimal.min(scaling.currentGain(conversion), unref(cap))
};
}

View file

@ -1,4 +1,10 @@
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature";
import GridComponent from "features/grids/Grid.vue";
import type { Persistent, State } from "game/persistence";
@ -15,14 +21,22 @@ import { createLazyProxy } from "util/proxies";
import type { Ref } from "vue";
import { computed, unref } from "vue";
/** A symbol used to identify {@link Grid} features. */
export const GridType = Symbol("Grid");
/** A type representing a computable value for a cell in the grid. */
export type CellComputable<T> = Computable<T> | ((id: string | number, state: State) => T);
/** Create proxy to more easily get the properties of cells on a grid. */
function createGridProxy(grid: GenericGrid): Record<string | number, GridCell> {
return new Proxy({}, getGridHandler(grid)) as Record<string | number, GridCell>;
}
/**
* Returns traps for a proxy that will give cell proxies when accessing any numerical key.
* @param grid The grid to get the cells from.
* @see {@link createGridProxy}
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function getGridHandler(grid: GenericGrid): ProxyHandler<Record<string | number, GridCell>> {
const keys = computed(() => {
@ -80,6 +94,12 @@ function getGridHandler(grid: GenericGrid): ProxyHandler<Record<string | number,
};
}
/**
* Returns traps for a proxy that will get the properties for the specified cell
* @param id The grid cell ID to get properties from.
* @see {@link getGridHandler}
* @see {@link createGridProxy}
*/
function getCellHandler(id: string): ProxyHandler<GenericGrid> {
const keys = [
"id",
@ -169,47 +189,90 @@ function getCellHandler(id: string): ProxyHandler<GenericGrid> {
};
}
/**
* Represents a cell within a grid. These properties will typically be accessed via a cell proxy that calls functions on the grid to get the properties for a specific cell.
* @see {@link createGridProxy}
*/
export interface GridCell {
/** A unique identifier for the grid cell. */
id: string;
/** Whether this cell should be visible. */
visibility: Visibility | boolean;
/** Whether this cell can be clicked. */
canClick: boolean;
/** The initial persistent state of this cell. */
startState: State;
/** The persistent state of this cell. */
state: State;
/** CSS to apply to this feature. */
style?: StyleValue;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Record<string, boolean>;
/** A header to appear at the top of the display. */
title?: CoercableComponent;
/** The main text that appears in the display. */
display: CoercableComponent;
/** A function that is called when the cell is clicked. */
onClick?: (e?: MouseEvent | TouchEvent) => void;
/** A function that is called when the cell is held down. */
onHold?: VoidFunction;
}
/**
* An object that configures a {@link Grid}.
*/
export interface GridOptions {
/** Whether this grid should be visible. */
visibility?: Computable<Visibility | boolean>;
/** The number of rows in the grid. */
rows: Computable<number>;
/** The number of columns in the grid. */
cols: Computable<number>;
/** A computable to determine the visibility of a cell. */
getVisibility?: CellComputable<Visibility | boolean>;
/** A computable to determine if a cell can be clicked. */
getCanClick?: CellComputable<boolean>;
/** A computable to get the initial persistent state of a cell. */
getStartState: Computable<State> | ((id: string | number) => State);
/** A computable to get the CSS styles for a cell. */
getStyle?: CellComputable<StyleValue>;
/** A computable to get the CSS classes for a cell. */
getClasses?: CellComputable<Record<string, boolean>>;
/** A computable to get the title component for a cell. */
getTitle?: CellComputable<CoercableComponent>;
/** A computable to get the display component for a cell. */
getDisplay: CellComputable<CoercableComponent>;
/** A function that is called when a cell is clicked. */
onClick?: (id: string | number, state: State, e?: MouseEvent | TouchEvent) => void;
/** A function that is called when a cell is held down. */
onHold?: (id: string | number, state: State) => void;
}
/**
* The properties that are added onto a processed {@link BoardOptions} to create a {@link Board}.
*/
export interface BaseGrid {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** Get the auto-generated ID for identifying a specific cell of this grid that appears in the DOM. Will not persist between refreshes or updates. */
getID: (id: string | number, state: State) => string;
/** Get the persistent state of the given cell. */
getState: (id: string | number) => State;
/** Set the persistent state of the given cell. */
setState: (id: string | number, state: State) => void;
/** A dictionary of cells within this grid. */
cells: Record<string | number, GridCell>;
/** The persistent state of this grid, which is a dictionary of cell states. */
cellState: Persistent<Record<string | number, State>>;
/** A symbol that helps identify features of the same type. */
type: typeof GridType;
[Component]: typeof GridComponent;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represents a feature that is a grid of cells that all behave according to the same rules. */
export type Grid<T extends GridOptions> = Replace<
T & BaseGrid,
{
@ -226,6 +289,7 @@ export type Grid<T extends GridOptions> = Replace<
}
>;
/** A type that matches any valid {@link Grid} object. */
export type GenericGrid = Replace<
Grid<GridOptions>,
{
@ -235,6 +299,10 @@ export type GenericGrid = Replace<
}
>;
/**
* Lazily creates a grid with the given options.
* @param optionsFunc Grid options.
*/
export function createGrid<T extends GridOptions>(
optionsFunc: OptionsFunc<T, BaseGrid, GenericGrid>
): Grid<T> {
@ -242,7 +310,7 @@ export function createGrid<T extends GridOptions>(
return createLazyProxy(() => {
const grid = optionsFunc();
grid.id = getUniqueID("grid-");
grid[Component] = GridComponent;
grid[Component] = GridComponent as GenericComponent;
grid.cellState = cellState;

View file

@ -15,20 +15,34 @@ import { createLazyProxy } from "util/proxies";
import { shallowReactive, unref } from "vue";
import Hotkey from "components/Hotkey.vue";
/** A dictionary of all hotkeys. */
export const hotkeys: Record<string, GenericHotkey | undefined> = shallowReactive({});
/** A symbol used to identify {@link Hotkey} features. */
export const HotkeyType = Symbol("Hotkey");
/**
* An object that configures a {@link Hotkey}.
*/
export interface HotkeyOptions {
/** Whether or not this hotkey is currently enabled. */
enabled?: Computable<boolean>;
/** The key tied to this hotkey */
key: string;
/** The description of this hotkey, to display in the settings. */
description: Computable<string>;
/** What to do upon pressing the key. */
onPress: VoidFunction;
}
/**
* The properties that are added onto a processed {@link HotkeyOptions} to create an {@link Hotkey}.
*/
export interface BaseHotkey {
/** A symbol that helps identify features of the same type. */
type: typeof HotkeyType;
}
/** An object that represents a hotkey shortcut that performs an action upon a key sequence being pressed. */
export type Hotkey<T extends HotkeyOptions> = Replace<
T & BaseHotkey,
{
@ -37,6 +51,7 @@ export type Hotkey<T extends HotkeyOptions> = Replace<
}
>;
/** A type that matches any valid {@link Hotkey} object. */
export type GenericHotkey = Replace<
Hotkey<HotkeyOptions>,
{
@ -46,6 +61,10 @@ export type GenericHotkey = Replace<
const uppercaseNumbers = [")", "!", "@", "#", "$", "%", "^", "&", "*", "("];
/**
* Lazily creates a hotkey with the given options.
* @param optionsFunc Hotkey options.
*/
export function createHotkey<T extends HotkeyOptions>(
optionsFunc: OptionsFunc<T, BaseHotkey, GenericHotkey>
): Hotkey<T> {

View file

@ -1,4 +1,10 @@
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature";
import InfoboxComponent from "features/infoboxes/Infobox.vue";
import type { Persistent } from "game/persistence";
@ -13,27 +19,48 @@ import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { unref } from "vue";
/** A symbol used to identify {@link Infobox} features. */
export const InfoboxType = Symbol("Infobox");
/**
* An object that configures an {@link Infobox}.
*/
export interface InfoboxOptions {
/** Whether this clickable should be visible. */
visibility?: Computable<Visibility | boolean>;
/** The background color of the Infobox. */
color?: Computable<string>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** CSS to apply to the title of the infobox. */
titleStyle?: Computable<StyleValue>;
/** CSS to apply to the body of the infobox. */
bodyStyle?: Computable<StyleValue>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** A header to appear at the top of the display. */
title: Computable<CoercableComponent>;
/** The main text that appears in the display. */
display: Computable<CoercableComponent>;
}
/**
* The properties that are added onto a processed {@link InfoboxOptions} to create an {@link Infobox}.
*/
export interface BaseInfobox {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** Whether or not this infobox is collapsed. */
collapsed: Persistent<boolean>;
/** A symbol that helps identify features of the same type. */
type: typeof InfoboxType;
[Component]: typeof InfoboxComponent;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represents a feature that displays information in a collapsible way. */
export type Infobox<T extends InfoboxOptions> = Replace<
T & BaseInfobox,
{
@ -48,6 +75,7 @@ export type Infobox<T extends InfoboxOptions> = Replace<
}
>;
/** A type that matches any valid {@link Infobox} object. */
export type GenericInfobox = Replace<
Infobox<InfoboxOptions>,
{
@ -55,6 +83,10 @@ export type GenericInfobox = Replace<
}
>;
/**
* Lazily creates an infobox with the given options.
* @param optionsFunc Infobox options.
*/
export function createInfobox<T extends InfoboxOptions>(
optionsFunc: OptionsFunc<T, BaseInfobox, GenericInfobox>
): Infobox<T> {
@ -63,7 +95,7 @@ export function createInfobox<T extends InfoboxOptions>(
const infobox = optionsFunc();
infobox.id = getUniqueID("infobox-");
infobox.type = InfoboxType;
infobox[Component] = InfoboxComponent;
infobox[Component] = InfoboxComponent as GenericComponent;
infobox.collapsed = collapsed;

View file

@ -1,4 +1,4 @@
import type { OptionsFunc, Replace } from "features/feature";
import type { GenericComponent, OptionsFunc, Replace } from "features/feature";
import { GatherProps, Component } from "features/feature";
import type { Position } from "game/layers";
import type { Computable, GetComputableType, ProcessedComputable } from "util/computed";
@ -7,8 +7,10 @@ import { createLazyProxy } from "util/proxies";
import type { SVGAttributes } from "vue";
import LinksComponent from "./Links.vue";
/** A symbol used to identify {@link Links} features. */
export const LinksType = Symbol("Links");
/** Represents a link between two nodes. It will be displayed as an SVG line, and can take any appropriate properties for an SVG line element. */
export interface Link extends SVGAttributes {
startNode: { id: string };
endNode: { id: string };
@ -16,16 +18,25 @@ export interface Link extends SVGAttributes {
offsetEnd?: Position;
}
/** An object that configures a {@link Links}. */
export interface LinksOptions {
/** The list of links to display. */
links: Computable<Link[]>;
}
/**
* The properties that are added onto a processed {@link LinksOptions} to create an {@link Links}.
*/
export interface BaseLinks {
/** A symbol that helps identify features of the same type. */
type: typeof LinksType;
[Component]: typeof LinksComponent;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represents a list of links between nodes, which are the elements in the DOM for any renderable feature. */
export type Links<T extends LinksOptions> = Replace<
T & BaseLinks,
{
@ -33,6 +44,7 @@ export type Links<T extends LinksOptions> = Replace<
}
>;
/** A type that matches any valid {@link Links} object. */
export type GenericLinks = Replace<
Links<LinksOptions>,
{
@ -40,13 +52,17 @@ export type GenericLinks = Replace<
}
>;
/**
* Lazily creates links with the given options.
* @param optionsFunc Links options.
*/
export function createLinks<T extends LinksOptions>(
optionsFunc: OptionsFunc<T, BaseLinks, GenericLinks>
): Links<T> {
return createLazyProxy(() => {
const links = optionsFunc();
links.type = LinksType;
links[Component] = LinksComponent;
links[Component] = LinksComponent as GenericComponent;
processComputable(links as T, "links");

View file

@ -1,128 +0,0 @@
<template>
<div
v-if="isVisible(visibility)"
:style="[
{
visibility: isHidden(visibility) ? 'hidden' : undefined
},
unref(style) ?? {}
]"
:class="{ feature: true, milestone: true, done: unref(earned), ...unref(classes) }"
>
<component :is="unref(comp)" />
<Node :id="id" />
</div>
</template>
<script lang="tsx">
import "components/common/features.css";
import Node from "components/Node.vue";
import type { StyleValue } from "features/feature";
import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import type { GenericMilestone } from "features/milestones/milestone";
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
import type { Component, UnwrapRef } from "vue";
import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
export default defineComponent({
props: {
visibility: {
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
display: {
type: processedPropType<UnwrapRef<GenericMilestone["display"]>>(
String,
Object,
Function
),
required: true
},
style: processedPropType<StyleValue>(String, Object, Array),
classes: processedPropType<Record<string, boolean>>(Object),
earned: {
type: processedPropType<boolean>(Boolean),
required: true
},
id: {
type: String,
required: true
}
},
components: {
Node
},
setup(props) {
const { display } = toRefs(props);
const comp = shallowRef<Component | string>("");
watchEffect(() => {
const currDisplay = unwrapRef(display);
if (currDisplay == null) {
comp.value = "";
return;
}
if (isCoercableComponent(currDisplay)) {
comp.value = coerceComponent(currDisplay);
return;
}
const Requirement = coerceComponent(currDisplay.requirement, "h3");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "", "b");
const OptionsDisplay = coerceComponent(currDisplay.optionsDisplay || "", "span");
comp.value = coerceComponent(
jsx(() => (
<span>
<Requirement />
{currDisplay.effectDisplay != null ? (
<div>
<EffectDisplay />
</div>
) : null}
{currDisplay.optionsDisplay != null ? (
<div class="equal-spaced">
<OptionsDisplay />
</div>
) : null}
</span>
))
);
});
return {
comp,
unref,
Visibility,
isVisible,
isHidden
};
}
});
</script>
<style scoped>
.milestone {
width: calc(100% - 10px);
min-width: 120px;
padding-left: 5px;
padding-right: 5px;
background-color: var(--locked);
border-width: 4px;
border-radius: 5px;
color: rgba(0, 0, 0, 0.5);
}
.milestone.done {
background-color: var(--bought);
cursor: default;
}
.milestone :deep(.equal-spaced) {
display: flex;
justify-content: center;
}
.milestone :deep(.equal-spaced > *) {
margin: auto;
}
</style>

View file

@ -1,248 +0,0 @@
import Select from "components/fields/Select.vue";
import { Decorator } from "features/decorators/common";
import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import {
Component,
GatherProps,
getUniqueID,
isVisible,
jsx,
setDefault,
Visibility
} from "features/feature";
import MilestoneComponent from "features/milestones/Milestone.vue";
import { globalBus } from "game/events";
import "game/notifications";
import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence";
import player from "game/player";
import settings, { registerSettingField } from "game/settings";
import { camelToTitle } from "util/common";
import type {
Computable,
GetComputableType,
GetComputableTypeWithDefault,
ProcessedComputable
} from "util/computed";
import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { coerceComponent, isCoercableComponent } from "util/vue";
import { computed, unref, watchEffect } from "vue";
import { useToast } from "vue-toastification";
const toast = useToast();
export const MilestoneType = Symbol("Milestone");
export enum MilestoneDisplay {
All = "all",
//Last = "last",
Configurable = "configurable",
Incomplete = "incomplete",
None = "none"
}
export interface MilestoneOptions {
visibility?: Computable<Visibility | boolean>;
shouldEarn?: () => boolean;
style?: Computable<StyleValue>;
classes?: Computable<Record<string, boolean>>;
display?: Computable<
| CoercableComponent
| {
requirement: CoercableComponent;
effectDisplay?: CoercableComponent;
optionsDisplay?: CoercableComponent;
}
>;
showPopups?: Computable<boolean>;
onComplete?: VoidFunction;
}
export interface BaseMilestone {
id: string;
earned: Persistent<boolean>;
complete: VoidFunction;
type: typeof MilestoneType;
[Component]: typeof MilestoneComponent;
[GatherProps]: () => Record<string, unknown>;
}
export type Milestone<T extends MilestoneOptions> = Replace<
T & BaseMilestone,
{
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
style: GetComputableType<T["style"]>;
classes: GetComputableType<T["classes"]>;
display: GetComputableType<T["display"]>;
showPopups: GetComputableType<T["showPopups"]>;
}
>;
export type GenericMilestone = Replace<
Milestone<MilestoneOptions>,
{
visibility: ProcessedComputable<Visibility | boolean>;
}
>;
export function createMilestone<T extends MilestoneOptions>(
optionsFunc?: OptionsFunc<T, BaseMilestone, GenericMilestone>,
...decorators: Decorator<T, BaseMilestone, GenericMilestone>[]
): Milestone<T> {
const earned = persistent<boolean>(false, false);
const decoratedData = decorators.reduce((current, next) => Object.assign(current, next.getPersistentData?.()), {});
return createLazyProxy(() => {
const milestone = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>);
milestone.id = getUniqueID("milestone-");
milestone.type = MilestoneType;
milestone[Component] = MilestoneComponent;
for (const decorator of decorators) {
decorator.preConstruct?.(milestone);
}
milestone.earned = earned;
Object.assign(milestone, decoratedData);
milestone.complete = function () {
const genericMilestone = milestone as GenericMilestone;
earned.value = true;
genericMilestone.onComplete?.();
if (genericMilestone.display != null && unref(genericMilestone.showPopups) === true) {
const display = unref(genericMilestone.display);
const Display = coerceComponent(
isCoercableComponent(display) ? display : display.requirement
);
toast(
<>
<h3>Milestone earned!</h3>
<div>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<Display />
</div>
</>
);
}
};
processComputable(milestone as T, "visibility");
setDefault(milestone, "visibility", Visibility.Visible);
const visibility = milestone.visibility as ProcessedComputable<Visibility | boolean>;
milestone.visibility = computed(() => {
const display = unref((milestone as GenericMilestone).display);
switch (settings.msDisplay) {
default:
case MilestoneDisplay.All:
return unref(visibility);
case MilestoneDisplay.Configurable:
if (
unref(milestone.earned) &&
!(
display != null &&
typeof display == "object" &&
"optionsDisplay" in (display as Record<string, unknown>)
)
) {
return Visibility.None;
}
return unref(visibility);
case MilestoneDisplay.Incomplete:
if (unref(milestone.earned)) {
return Visibility.None;
}
return unref(visibility);
case MilestoneDisplay.None:
return Visibility.None;
}
});
processComputable(milestone as T, "style");
processComputable(milestone as T, "classes");
processComputable(milestone as T, "display");
processComputable(milestone as T, "showPopups");
for (const decorator of decorators) {
decorator.postConstruct?.(milestone);
}
const decoratedProps = decorators.reduce((current, next) => Object.assign(current, next?.getGatheredProps?.(milestone)), {});
milestone[GatherProps] = function (this: GenericMilestone) {
const { visibility, display, style, classes, earned, id } = this;
return { visibility, display, style: unref(style), classes, earned, id, ...decoratedProps };
};
if (milestone.shouldEarn) {
const genericMilestone = milestone as GenericMilestone;
watchEffect(() => {
if (settings.active !== player.id) return;
if (
!genericMilestone.earned.value &&
isVisible(genericMilestone.visibility) &&
genericMilestone.shouldEarn?.()
) {
genericMilestone.earned.value = true;
genericMilestone.onComplete?.();
if (
genericMilestone.display != null &&
unref(genericMilestone.showPopups) === true
) {
const display = unref(genericMilestone.display);
const Display = coerceComponent(
isCoercableComponent(display) ? display : display.requirement
);
toast(
<>
<h3>Milestone earned!</h3>
<div>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<Display />
</div>
</>
);
}
}
});
}
return milestone as unknown as Milestone<T>;
});
}
declare module "game/settings" {
interface Settings {
msDisplay: MilestoneDisplay;
}
}
globalBus.on("loadSettings", settings => {
setDefault(settings, "msDisplay", MilestoneDisplay.All);
});
const msDisplayOptions = Object.values(MilestoneDisplay).map(option => ({
label: camelToTitle(option),
value: option
}));
registerSettingField(
jsx(() => (
<Select
title={jsx(() => (
<span class="option-title">
Show milestones
<desc>Select which milestones to display based on criterias.</desc>
</span>
))}
options={msDisplayOptions}
onUpdate:modelValue={value => (settings.msDisplay = value as MilestoneDisplay)}
modelValue={settings.msDisplay}
/>
))
);

View file

@ -8,24 +8,49 @@ import type { Computable, GetComputableType } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { Ref, shallowRef, unref } from "vue";
/** A symbol used to identify {@link Particles} features. */
export const ParticlesType = Symbol("Particles");
/**
* An object that configures {@link Particles}.
*/
export interface ParticlesOptions {
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** A function that is called when the particles canvas is resized. */
onContainerResized?: (boundingRect: DOMRect) => void;
/** A function that is called whenever the particles element is reloaded during development. For restarting particle effects. */
onHotReload?: VoidFunction;
}
/**
* The properties that are added onto a processed {@link ParticlesOptions} to create an {@link Particles}.
*/
export interface BaseParticles {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** The Pixi.JS Application powering this particles canvas. */
app: Ref<null | Application>;
/**
* A function to asynchronously add an emitter to the canvas.
* The returned emitter can then be positioned as appropriate and started.
* @see {@link Particles}
*/
addEmitter: (config: EmitterConfigV3) => Promise<Emitter>;
/** A symbol that helps identify features of the same type. */
type: typeof ParticlesType;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/**
* An object that represents a feature that display particle effects on the screen.
* The config should typically be gotten by designing the effect using the [online particle effect editor](https://pixijs.io/pixi-particles-editor/) and passing it into the {@link upgradeConfig} from @pixi/particle-emitter.
*/
export type Particles<T extends ParticlesOptions> = Replace<
T & BaseParticles,
{
@ -34,8 +59,13 @@ export type Particles<T extends ParticlesOptions> = Replace<
}
>;
/** A type that matches any valid {@link Particles} object. */
export type GenericParticles = Particles<ParticlesOptions>;
/**
* Lazily creates particles with the given options.
* @param optionsFunc Particles options.
*/
export function createParticles<T extends ParticlesOptions>(
optionsFunc?: OptionsFunc<T, BaseParticles, GenericParticles>
): Particles<T> {

View file

@ -1,6 +1,12 @@
import { isArray } from "@vue/shared";
import ClickableComponent from "features/clickables/Clickable.vue";
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import { Component, GatherProps, getUniqueID, jsx, setDefault, Visibility } from "features/feature";
import { DefaultValue, Persistent, persistent } from "game/persistence";
import {
@ -37,7 +43,7 @@ export type RepeatableDisplay =
title?: CoercableComponent;
/** The main text that appears in the display. */
description?: CoercableComponent;
/** A description of the current effect of this repeatable, bsed off its amount. */
/** A description of the current effect of this repeatable, based off its amount. */
effectDisplay?: CoercableComponent;
/** Whether or not to show the current amount of this repeatable at the bottom of the display. */
showAmount?: boolean;
@ -71,7 +77,7 @@ export interface RepeatableOptions {
* The properties that are added onto a processed {@link RepeatableOptions} to create a {@link Repeatable}.
*/
export interface BaseRepeatable {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persistent between refreshes or updates. */
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** The current amount this repeatable has. */
amount: Persistent<DecimalSource>;
@ -79,12 +85,17 @@ export interface BaseRepeatable {
maxed: Ref<boolean>;
/** Whether or not this repeatable can be clicked. */
canClick: ProcessedComputable<boolean>;
/**
* How much amount can be increased by, or 1 if unclickable.
* Capped at 1 if {@link RepeatableOptions.maximize} is false.
**/
amountToIncrease: Ref<DecimalSource>;
/** A function that gets called when this repeatable is clicked. */
onClick: (event?: MouseEvent | TouchEvent) => void;
/** A symbol that helps identify features of the same type. */
type: typeof RepeatableType;
/** The Vue component used to render this feature. */
[Component]: typeof ClickableComponent;
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
@ -129,7 +140,7 @@ export function createRepeatable<T extends RepeatableOptions>(
repeatable.id = getUniqueID("repeatable-");
repeatable.type = RepeatableType;
repeatable[Component] = ClickableComponent;
repeatable[Component] = ClickableComponent as GenericComponent;
for (const decorator of decorators) {
decorator.preConstruct?.(repeatable);
@ -179,6 +190,11 @@ export function createRepeatable<T extends RepeatableOptions>(
}
return currClasses;
});
repeatable.amountToIncrease = computed(() =>
unref((repeatable as GenericRepeatable).maximize)
? maxRequirementsMet(repeatable.requirements)
: 1
);
repeatable.canClick = computed(() => requirementsMet(repeatable.requirements));
const onClick = repeatable.onClick;
repeatable.onClick = function (this: GenericRepeatable, event?: MouseEvent | TouchEvent) {
@ -186,12 +202,7 @@ export function createRepeatable<T extends RepeatableOptions>(
if (!unref(genericRepeatable.canClick)) {
return;
}
payRequirements(
repeatable.requirements,
unref(genericRepeatable.maximize)
? maxRequirementsMet(genericRepeatable.requirements)
: 1
);
payRequirements(repeatable.requirements, unref(repeatable.amountToIncrease));
genericRepeatable.amount.value = Decimal.add(genericRepeatable.amount.value, 1);
onClick?.(event);
};
@ -240,9 +251,7 @@ export function createRepeatable<T extends RepeatableOptions>(
<br />
{displayRequirements(
genericRepeatable.requirements,
unref(genericRepeatable.maximize)
? maxRequirementsMet(genericRepeatable.requirements)
: 1
unref(repeatable.amountToIncrease)
)}
</div>
)}

View file

@ -11,19 +11,32 @@ import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { isRef, unref } from "vue";
/** A symbol used to identify {@link Reset} features. */
export const ResetType = Symbol("Reset");
/**
* An object that configures a {@link Clickable}.
*/
export interface ResetOptions {
/** List of things to reset. Can include objects which will be recursed over for persistent values. */
thingsToReset: Computable<Record<string, unknown>[]>;
/** A function that is called when the reset is performed. */
onReset?: VoidFunction;
}
/**
* The properties that are added onto a processed {@link ResetOptions} to create an {@link Reset}.
*/
export interface BaseReset {
/** An auto-generated ID for identifying which reset is being performed. Will not persist between refreshes or updates. */
id: string;
/** Trigger the reset. */
reset: VoidFunction;
/** A symbol that helps identify features of the same type. */
type: typeof ResetType;
}
/** An object that represents a reset mechanic, which resets progress back to its initial state. */
export type Reset<T extends ResetOptions> = Replace<
T & BaseReset,
{
@ -31,8 +44,13 @@ export type Reset<T extends ResetOptions> = Replace<
}
>;
/** A type that matches any valid {@link Reset} object. */
export type GenericReset = Reset<ResetOptions>;
/**
* Lazily creates a reset with the given options.
* @param optionsFunc Reset options.
*/
export function createReset<T extends ResetOptions>(
optionsFunc: OptionsFunc<T, BaseReset, GenericReset>
): Reset<T> {
@ -66,6 +84,11 @@ export function createReset<T extends ResetOptions>(
}
const listeners: Record<string, Unsubscribe | undefined> = {};
/**
* Track the time since the specified reset last occured.
* @param layer The layer the reset is attached to
* @param reset The reset mechanic to track the time since
*/
export function trackResetTime(layer: BaseLayer, reset: GenericReset): Persistent<Decimal> {
const resetTime = persistent<Decimal>(new Decimal(0));
globalBus.on("addLayer", layerBeingAdded => {

View file

@ -1,8 +1,6 @@
import { globalBus } from "game/events";
import { NonPersistent, Persistent, State } from "game/persistence";
import { persistent } from "game/persistence";
import player from "game/player";
import settings from "game/settings";
import type { Persistent, State } from "game/persistence";
import { NonPersistent, persistent } from "game/persistence";
import type { DecimalSource } from "util/bignum";
import Decimal, { format, formatWhole } from "util/bignum";
import type { ProcessedComputable } from "util/computed";
@ -10,12 +8,23 @@ import { loadingSave } from "util/save";
import type { ComputedRef, Ref } from "vue";
import { computed, isRef, ref, unref, watch } from "vue";
/** An object that represents a named and quantifiable resource in the game. */
export interface Resource<T = DecimalSource> extends Ref<T> {
/** The name of this resource. */
displayName: string;
/** When displaying the value of this resource, how many significant digits to display. */
precision: number;
/** Whether or not to display very small values using scientific notation, or rounding to 0. */
small?: boolean;
}
/**
* Creates a resource.
* @param defaultValue The initial value of the resource
* @param displayName The human readable name of this resource
* @param precision The number of significant digits to display by default
* @param small Whether or not to display very small values or round to 0, by default
*/
export function createResource<T extends State>(
defaultValue: T,
displayName?: string,
@ -51,6 +60,7 @@ export function createResource<T extends State>(
return resource as Resource<T>;
}
/** Returns a reference to the highest amount of the resource ever owned, which is updated automatically. */
export function trackBest(resource: Resource): Ref<DecimalSource> {
const best = persistent(resource.value);
watch(resource, amount => {
@ -64,6 +74,7 @@ export function trackBest(resource: Resource): Ref<DecimalSource> {
return best;
}
/** Returns a reference to the total amount of the resource gained, updated automatically. "Refunds" count as gain. */
export function trackTotal(resource: Resource): Ref<DecimalSource> {
const total = persistent(resource.value);
watch(resource, (amount, prevAmount) => {
@ -79,6 +90,7 @@ export function trackTotal(resource: Resource): Ref<DecimalSource> {
const tetra8 = new Decimal("10^^8");
const e100 = new Decimal("1e100");
/** Returns a reference to the amount of resource being gained in terms of orders of magnitude per second, calcualted over the last tick. Useful for situations where the gain rate is increasing very rapidly. */
export function trackOOMPS(
resource: Resource,
pointGain?: ComputedRef<DecimalSource>
@ -137,6 +149,7 @@ export function trackOOMPS(
return oompsString;
}
/** Utility for displaying a resource with the correct precision. */
export function displayResource(resource: Resource, overrideAmount?: DecimalSource): string {
const amount = overrideAmount ?? resource.value;
if (Decimal.eq(resource.precision, 0)) {
@ -145,6 +158,7 @@ export function displayResource(resource: Resource, overrideAmount?: DecimalSour
return format(amount, resource.precision, resource.small);
}
/** Utility for unwrapping a resource that may or may not be inside a ref. */
export function unwrapResource(resource: ProcessedComputable<Resource>): Resource {
if ("displayName" in resource) {
return resource;

View file

@ -1,24 +1,48 @@
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import { Component, GatherProps, getUniqueID } from "features/feature";
import TabComponent from "features/tabs/Tab.vue";
import type { Computable, GetComputableType } from "util/computed";
import { createLazyProxy } from "util/proxies";
/** A symbol used to identify {@link Tab} features. */
export const TabType = Symbol("Tab");
/**
* An object that configures a {@link Tab}.
*/
export interface TabOptions {
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** The display to use for this tab. */
display: Computable<CoercableComponent>;
}
/**
* The properties that are added onto a processed {@link TabOptions} to create an {@link Tab}.
*/
export interface BaseTab {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** A symbol that helps identify features of the same type. */
type: typeof TabType;
[Component]: typeof TabComponent;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/**
* An object representing a tab of content in a tabbed interface.
* @see {@link TabFamily}
*/
export type Tab<T extends TabOptions> = Replace<
T & BaseTab,
{
@ -28,8 +52,13 @@ export type Tab<T extends TabOptions> = Replace<
}
>;
/** A type that matches any valid {@link Tab} object. */
export type GenericTab = Tab<TabOptions>;
/**
* Lazily creates a tab with the given options.
* @param optionsFunc Tab options.
*/
export function createTab<T extends TabOptions>(
optionsFunc: OptionsFunc<T, BaseTab, GenericTab>
): Tab<T> {
@ -37,7 +66,7 @@ export function createTab<T extends TabOptions>(
const tab = optionsFunc();
tab.id = getUniqueID("tab-");
tab.type = TabType;
tab[Component] = TabComponent;
tab[Component] = TabComponent as GenericComponent;
tab[GatherProps] = function (this: GenericTab) {
const { display } = this;

View file

@ -1,4 +1,10 @@
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import {
Component,
GatherProps,
@ -23,23 +29,43 @@ import type { Ref } from "vue";
import { computed, unref } from "vue";
import type { GenericTab } from "./tab";
/** A symbol used to identify {@link TabButton} features. */
export const TabButtonType = Symbol("TabButton");
/** A symbol used to identify {@link TabFamily} features. */
export const TabFamilyType = Symbol("TabFamily");
/**
* An object that configures a {@link TabButton}.
*/
export interface TabButtonOptions {
/** Whether this tab button should be visible. */
visibility?: Computable<Visibility | boolean>;
/** The tab to display when this button is clicked. */
tab: Computable<GenericTab | CoercableComponent>;
/** The label on this button. */
display: Computable<CoercableComponent>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** The color of the glow effect to display when this button is active. */
glowColor?: Computable<string>;
}
/**
* The properties that are added onto a processed {@link TabButtonOptions} to create an {@link TabButton}.
*/
export interface BaseTabButton {
/** A symbol that helps identify features of the same type. */
type: typeof TabButtonType;
[Component]: typeof TabButtonComponent;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
}
/**
* An object that represents a button that can be clicked to change tabs in a tabbed interface.
* @see {@link TabFamily}
*/
export type TabButton<T extends TabButtonOptions> = Replace<
T & BaseTabButton,
{
@ -52,6 +78,7 @@ export type TabButton<T extends TabButtonOptions> = Replace<
}
>;
/** A type that matches any valid {@link TabButton} object. */
export type GenericTabButton = Replace<
TabButton<TabButtonOptions>,
{
@ -59,24 +86,46 @@ export type GenericTabButton = Replace<
}
>;
/**
* An object that configures a {@link TabFamily}.
*/
export interface TabFamilyOptions {
/** Whether this tab button should be visible. */
visibility?: Computable<Visibility | boolean>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** A dictionary of CSS classes to apply to the list of buttons for changing tabs. */
buttonContainerClasses?: Computable<Record<string, boolean>>;
/** CSS to apply to the list of buttons for changing tabs. */
buttonContainerStyle?: Computable<StyleValue>;
}
/**
* The properties that are added onto a processed {@link TabFamilyOptions} to create an {@link TabFamily}.
*/
export interface BaseTabFamily {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** All the tabs within this family. */
tabs: Record<string, TabButtonOptions>;
/** The currently active tab, if any. */
activeTab: Ref<GenericTab | CoercableComponent | null>;
/** The name of the tab that is currently active. */
selected: Persistent<string>;
/** A symbol that helps identify features of the same type. */
type: typeof TabFamilyType;
[Component]: typeof TabFamilyComponent;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/**
* An object that represents a tabbed interface.
* @see {@link TabFamily}
*/
export type TabFamily<T extends TabFamilyOptions> = Replace<
T & BaseTabFamily,
{
@ -85,6 +134,7 @@ export type TabFamily<T extends TabFamilyOptions> = Replace<
}
>;
/** A type that matches any valid {@link TabFamily} object. */
export type GenericTabFamily = Replace<
TabFamily<TabFamilyOptions>,
{
@ -92,13 +142,17 @@ export type GenericTabFamily = Replace<
}
>;
/**
* Lazily creates a tab family with the given options.
* @param optionsFunc Tab family options.
*/
export function createTabFamily<T extends TabFamilyOptions>(
tabs: Record<string, () => TabButtonOptions>,
optionsFunc?: OptionsFunc<T, BaseTabFamily, GenericTabFamily>
): TabFamily<T> {
if (Object.keys(tabs).length === 0) {
console.warn("Cannot create tab family with 0 tabs");
throw "Cannot create tab family with 0 tabs";
throw new Error("Cannot create tab family with 0 tabs");
}
const selected = persistent(Object.keys(tabs)[0], false);
@ -107,13 +161,13 @@ export function createTabFamily<T extends TabFamilyOptions>(
tabFamily.id = getUniqueID("tabFamily-");
tabFamily.type = TabFamilyType;
tabFamily[Component] = TabFamilyComponent;
tabFamily[Component] = TabFamilyComponent as GenericComponent;
tabFamily.tabs = Object.keys(tabs).reduce<Record<string, GenericTabButton>>(
(parsedTabs, tab) => {
const tabButton: TabButtonOptions & Partial<BaseTabButton> = tabs[tab]();
tabButton.type = TabButtonType;
tabButton[Component] = TabButtonComponent;
tabButton[Component] = TabButtonComponent as GenericComponent;
processComputable(tabButton as TabButtonOptions, "visibility");
setDefault(tabButton, "visibility", Visibility.Visible);

View file

@ -1,6 +1,6 @@
import type { CoercableComponent, Replace, StyleValue } from "features/feature";
import { Component, GatherProps, setDefault } from "features/feature";
import { persistent } from "game/persistence";
import { deletePersistent, Persistent, persistent } from "game/persistence";
import { Direction } from "util/common";
import type {
Computable,
@ -21,20 +21,34 @@ declare module "@vue/runtime-dom" {
}
}
/**
* An object that configures a {@link Tooltip}.
*/
export interface TooltipOptions {
/** Whether or not this tooltip can be pinned, meaning it'll stay visible even when not hovered. */
pinnable?: boolean;
/** The text to display inside the tooltip. */
display: Computable<CoercableComponent>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** The direction in which to display the tooltip */
direction?: Computable<Direction>;
/** The x offset of the tooltip, in px. */
xoffset?: Computable<string>;
/** The y offset of the tooltip, in px. */
yoffset?: Computable<string>;
}
/**
* The properties that are added onto a processed {@link TooltipOptions} to create an {@link Tooltip}.
*/
export interface BaseTooltip {
pinned?: Ref<boolean>;
}
/** An object that represents a tooltip that appears when hovering over an element. */
export type Tooltip<T extends TooltipOptions> = Replace<
T & BaseTooltip,
{
@ -49,6 +63,7 @@ export type Tooltip<T extends TooltipOptions> = Replace<
}
>;
/** A type that matches any valid {@link Tooltip} object. */
export type GenericTooltip = Replace<
Tooltip<TooltipOptions>,
{
@ -58,6 +73,11 @@ export type GenericTooltip = Replace<
}
>;
/**
* Creates a tooltip on the given element with the given options.
* @param element The renderable feature to display the tooltip on.
* @param optionsFunc Clickable options.
*/
export function addTooltip<T extends TooltipOptions>(
element: VueFeature,
options: T & ThisType<Tooltip<T>> & Partial<BaseTooltip>
@ -70,19 +90,23 @@ export function addTooltip<T extends TooltipOptions>(
processComputable(options as T, "xoffset");
processComputable(options as T, "yoffset");
if (options.pinnable) {
options.pinned = persistent<boolean>(false, false);
}
nextTick(() => {
if (options.pinnable) {
if ("pinned" in element) {
console.error(
"Cannot add pinnable tooltip to element that already has a property called 'pinned'"
);
options.pinnable = false;
deletePersistent(options.pinned as Persistent<boolean>);
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(element as any).pinned = options.pinned = persistent<boolean>(false, false);
(element as any).pinned = options.pinned;
}
}
nextTick(() => {
const elementComponent = element[Component];
element[Component] = TooltipComponent;
const elementGatherProps = element[GatherProps].bind(element);

View file

@ -1,5 +1,11 @@
import { Decorator } from "features/decorators/common";
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature";
import type { Link } from "features/links/links";
import type { GenericReset } from "features/reset";
@ -20,30 +26,54 @@ import { createLazyProxy } from "util/proxies";
import type { Ref } from "vue";
import { computed, ref, shallowRef, unref } from "vue";
/** A symbol used to identify {@link TreeNode} features. */
export const TreeNodeType = Symbol("TreeNode");
/** A symbol used to identify {@link Tree} features. */
export const TreeType = Symbol("Tree");
/**
* An object that configures a {@link TreeNode}.
*/
export interface TreeNodeOptions {
/** Whether this tree node should be visible. */
visibility?: Computable<Visibility | boolean>;
/** Whether or not this tree node can be clicked. */
canClick?: Computable<boolean>;
/** The background color for this node. */
color?: Computable<string>;
/** The label to display on this tree node. */
display?: Computable<CoercableComponent>;
/** The color of the glow effect shown to notify the user there's something to do with this node. */
glowColor?: Computable<string>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>;
/** A reset object attached to this node, used for propagating resets through the tree. */
reset?: GenericReset;
/** A function that is called when the tree node is clicked. */
onClick?: (e?: MouseEvent | TouchEvent) => void;
/** A function that is called when the tree node is held down. */
onHold?: VoidFunction;
}
/**
* The properties that are added onto a processed {@link TreeNodeOptions} to create an {@link TreeNode}.
*/
export interface BaseTreeNode {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** A symbol that helps identify features of the same type. */
type: typeof TreeNodeType;
[Component]: typeof TreeNodeComponent;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represents a node on a tree. */
export type TreeNode<T extends TreeNodeOptions> = Replace<
T & BaseTreeNode,
{
@ -58,6 +88,7 @@ export type TreeNode<T extends TreeNodeOptions> = Replace<
}
>;
/** A type that matches any valid {@link TreeNode} object. */
export type GenericTreeNode = Replace<
TreeNode<TreeNodeOptions>,
{
@ -66,6 +97,10 @@ export type GenericTreeNode = Replace<
}
>;
/**
* Lazily creates a tree node with the given options.
* @param optionsFunc Tree Node options.
*/
export function createTreeNode<T extends TreeNodeOptions>(
optionsFunc?: OptionsFunc<T, BaseTreeNode, GenericTreeNode>,
...decorators: Decorator<T, BaseTreeNode, GenericTreeNode>[]
@ -75,7 +110,7 @@ export function createTreeNode<T extends TreeNodeOptions>(
const treeNode = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>);
treeNode.id = getUniqueID("treeNode-");
treeNode.type = TreeNodeType;
treeNode[Component] = TreeNodeComponent;
treeNode[Component] = TreeNodeComponent as GenericComponent;
for (const decorator of decorators) {
decorator.preConstruct?.(treeNode);
@ -150,32 +185,52 @@ export function createTreeNode<T extends TreeNodeOptions>(
});
}
/** Represents a branch between two nodes in a tree. */
export interface TreeBranch extends Omit<Link, "startNode" | "endNode"> {
startNode: GenericTreeNode;
endNode: GenericTreeNode;
}
/**
* An object that configures a {@link Tree}.
*/
export interface TreeOptions {
/** Whether this clickable should be visible. */
visibility?: Computable<Visibility | boolean>;
/** The nodes within the tree, in a 2D array. */
nodes: Computable<GenericTreeNode[][]>;
/** Nodes to show on the left side of the tree. */
leftSideNodes?: Computable<GenericTreeNode[]>;
/** Nodes to show on the right side of the tree. */
rightSideNodes?: Computable<GenericTreeNode[]>;
/** The branches between nodes within this tree. */
branches?: Computable<TreeBranch[]>;
/** How to propagate resets through the tree. */
resetPropagation?: ResetPropagation;
/** A function that is called when a node within the tree is reset. */
onReset?: (node: GenericTreeNode) => void;
}
export interface BaseTree {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** The link objects for each of the branches of the tree. */
links: Ref<Link[]>;
/** Cause a reset on this node and propagate it through the tree according to {@link resetPropagation}. */
reset: (node: GenericTreeNode) => void;
/** A flag that is true while the reset is still propagating through the tree. */
isResetting: Ref<boolean>;
/** A reference to the node that caused the currently propagating reset. */
resettingNode: Ref<GenericTreeNode | null>;
/** A symbol that helps identify features of the same type. */
type: typeof TreeType;
[Component]: typeof TreeComponent;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represents a feature that is a tree of nodes with branches between them. Contains support for reset mechanics that can propagate through the tree. */
export type Tree<T extends TreeOptions> = Replace<
T & BaseTree,
{
@ -187,6 +242,7 @@ export type Tree<T extends TreeOptions> = Replace<
}
>;
/** A type that matches any valid {@link Tree} object. */
export type GenericTree = Replace<
Tree<TreeOptions>,
{
@ -194,6 +250,10 @@ export type GenericTree = Replace<
}
>;
/**
* Lazily creates a tree with the given options.
* @param optionsFunc Tree options.
*/
export function createTree<T extends TreeOptions>(
optionsFunc: OptionsFunc<T, BaseTree, GenericTree>
): Tree<T> {
@ -201,7 +261,7 @@ export function createTree<T extends TreeOptions>(
const tree = optionsFunc();
tree.id = getUniqueID("tree-");
tree.type = TreeType;
tree[Component] = TreeComponent;
tree[Component] = TreeComponent as GenericComponent;
tree.isResetting = ref(false);
tree.resettingNode = shallowRef(null);
@ -236,10 +296,12 @@ export function createTree<T extends TreeOptions>(
});
}
/** A function that is used to propagate resets through a tree. */
export type ResetPropagation = {
(tree: GenericTree, resettingNode: GenericTreeNode): void;
};
/** Propagate resets down the tree by resetting every node in a lower row. */
export const defaultResetPropagation = function (
tree: GenericTree,
resettingNode: GenericTreeNode
@ -251,6 +313,7 @@ export const defaultResetPropagation = function (
}
};
/** Propagate resets down the tree by resetting every node in a lower row. */
export const invertedResetPropagation = function (
tree: GenericTree,
resettingNode: GenericTreeNode
@ -262,6 +325,7 @@ export const invertedResetPropagation = function (
}
};
/** Propagate resets down the branches of the tree. */
export const branchedResetPropagation = function (
tree: GenericTree,
resettingNode: GenericTreeNode
@ -297,6 +361,10 @@ export const branchedResetPropagation = function (
}
};
/**
* Utility for creating a tooltip for a tree node that displays a resource-based unlock requirement, and after unlock shows the amount of another resource.
* It sounds oddly specific, but comes up a lot.
*/
export function createResourceTooltip(
resource: Resource,
requiredResource: Resource | null = null,

View file

@ -39,35 +39,60 @@ import { createLazyProxy } from "util/proxies";
import type { Ref } from "vue";
import { computed, unref } from "vue";
/** A symbol used to identify {@link Upgrade} features. */
export const UpgradeType = Symbol("Upgrade");
/**
* An object that configures a {@link Upgrade}.
*/
export interface UpgradeOptions {
/** Whether this clickable should be visible. */
visibility?: Computable<Visibility | boolean>;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>;
/** The display to use for this clickable. */
display?: Computable<
| CoercableComponent
| {
/** A header to appear at the top of the display. */
title?: CoercableComponent;
/** The main text that appears in the display. */
description: CoercableComponent;
/** A description of the current effect of the achievement. Useful when the effect changes dynamically. */
effectDisplay?: CoercableComponent;
}
>;
/** The requirements to purchase this upgrade. */
requirements: Requirements;
mark?: Computable<boolean | string>;
/** A function that is called when the upgrade is purchased. */
onPurchase?: VoidFunction;
}
/**
* The properties that are added onto a processed {@link UpgradeOptions} to create an {@link Upgrade}.
*/
export interface BaseUpgrade {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string;
/** Whether or not this upgrade has been purchased. */
bought: Persistent<boolean>;
/** Whether or not the upgrade can currently be purchased. */
canPurchase: Ref<boolean>;
/** Purchase the upgrade */
purchase: VoidFunction;
/** A symbol that helps identify features of the same type. */
type: typeof UpgradeType;
[Component]: typeof UpgradeComponent;
/** The Vue component used to render this feature. */
[Component]: GenericComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represents a feature that can be purchased a single time. */
export type Upgrade<T extends UpgradeOptions> = Replace<
T & BaseUpgrade,
{
@ -80,6 +105,7 @@ export type Upgrade<T extends UpgradeOptions> = Replace<
}
>;
/** A type that matches any valid {@link Upgrade} object. */
export type GenericUpgrade = Replace<
Upgrade<UpgradeOptions>,
{
@ -87,6 +113,10 @@ export type GenericUpgrade = Replace<
}
>;
/**
* Lazily creates an upgrade with the given options.
* @param optionsFunc Upgrade options.
*/
export function createUpgrade<T extends UpgradeOptions>(
optionsFunc: OptionsFunc<T, BaseUpgrade, GenericUpgrade>,
...decorators: Decorator<T, BaseUpgrade, GenericUpgrade>[]
@ -97,7 +127,7 @@ export function createUpgrade<T extends UpgradeOptions>(
const upgrade = optionsFunc();
upgrade.id = getUniqueID("upgrade-");
upgrade.type = UpgradeType;
upgrade[Component] = UpgradeComponent;
upgrade[Component] = UpgradeComponent as GenericComponent;
for (const decorator of decorators) {
decorator.preConstruct?.(upgrade);
@ -168,6 +198,12 @@ export function createUpgrade<T extends UpgradeOptions>(
});
}
/**
* Utility to auto purchase a list of upgrades whenever they're affordable.
* @param layer The layer the upgrades are apart of
* @param autoActive Whether or not the upgrades should currently be auto-purchasing
* @param upgrades The specific upgrades to upgrade. If unspecified, uses all upgrades on the layer.
*/
export function setupAutoPurchase(
layer: GenericLayer,
autoActive: Computable<boolean>,

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,705 @@
import Decimal, { DecimalSource } from "util/bignum";
import Formula, { hasVariable, unrefFormulaSource } from "./formulas";
import { FormulaSource, GenericFormula, InvertFunction, SubstitutionStack } from "./types";
const ln10 = Decimal.ln(10);
export function passthrough<T extends GenericFormula | DecimalSource>(value: T): T {
return value;
}
export function invertNeg(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.neg(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateNeg(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return Formula.neg(lhs.getIntegralFormula(stack));
}
throw new Error("Could not integrate due to no input being a variable");
}
export function applySubstitutionNeg(value: GenericFormula) {
return Formula.neg(value);
}
export function invertAdd(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.sub(value, unrefFormulaSource(rhs)));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.sub(value, unrefFormulaSource(lhs)));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateAdd(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.times(rhs, lhs.innermostVariable ?? 0).add(x);
} else if (hasVariable(rhs)) {
const x = rhs.getIntegralFormula(stack);
return Formula.times(lhs, rhs.innermostVariable ?? 0).add(x);
}
throw new Error("Could not integrate due to no input being a variable");
}
export function integrateInnerAdd(
stack: SubstitutionStack,
lhs: FormulaSource,
rhs: FormulaSource
) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.add(x, rhs);
} else if (hasVariable(rhs)) {
const x = rhs.getIntegralFormula(stack);
return Formula.add(x, lhs);
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertSub(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.add(value, unrefFormulaSource(rhs)));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.sub(unrefFormulaSource(lhs), value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateSub(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.sub(x, Formula.times(rhs, lhs.innermostVariable ?? 0));
} else if (hasVariable(rhs)) {
const x = rhs.getIntegralFormula(stack);
return Formula.times(lhs, rhs.innermostVariable ?? 0).sub(x);
}
throw new Error("Could not integrate due to no input being a variable");
}
export function integrateInnerSub(
stack: SubstitutionStack,
lhs: FormulaSource,
rhs: FormulaSource
) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.sub(x, rhs);
} else if (hasVariable(rhs)) {
const x = rhs.getIntegralFormula(stack);
return Formula.sub(x, lhs);
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertMul(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.div(value, unrefFormulaSource(rhs)));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.div(value, unrefFormulaSource(lhs)));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateMul(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.times(x, rhs);
} else if (hasVariable(rhs)) {
const x = rhs.getIntegralFormula(stack);
return Formula.times(x, lhs);
}
throw new Error("Could not integrate due to no input being a variable");
}
export function applySubstitutionMul(
value: GenericFormula,
lhs: FormulaSource,
rhs: FormulaSource
) {
if (hasVariable(lhs)) {
return Formula.div(value, rhs);
} else if (hasVariable(rhs)) {
return Formula.div(value, lhs);
}
throw new Error("Could not apply substitution due to no input being a variable");
}
export function invertDiv(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.mul(value, unrefFormulaSource(rhs)));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.div(unrefFormulaSource(lhs), value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateDiv(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.div(x, rhs);
} else if (hasVariable(rhs)) {
const x = rhs.getIntegralFormula(stack);
return Formula.div(lhs, x);
}
throw new Error("Could not integrate due to no input being a variable");
}
export function applySubstitutionDiv(
value: GenericFormula,
lhs: FormulaSource,
rhs: FormulaSource
) {
if (hasVariable(lhs)) {
return Formula.mul(value, rhs);
} else if (hasVariable(rhs)) {
return Formula.mul(value, lhs);
}
throw new Error("Could not apply substitution due to no input being a variable");
}
export function invertRecip(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.recip(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateRecip(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.ln(x);
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertLog10(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.pow10(value));
}
throw new Error("Could not invert due to no input being a variable");
}
function internalIntegrateLog10(lhs: DecimalSource) {
return Decimal.ln(lhs).sub(1).times(lhs).div(ln10);
}
function internalInvertIntegralLog10(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const numerator = ln10.times(value);
return lhs.invert(numerator.div(numerator.div(Math.E).lambertw()));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateLog10(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return new Formula({
inputs: [lhs.getIntegralFormula(stack)],
evaluate: internalIntegrateLog10,
invert: internalInvertIntegralLog10
});
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertLog(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.pow(unrefFormulaSource(rhs), value));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.root(unrefFormulaSource(lhs), value));
}
throw new Error("Could not invert due to no input being a variable");
}
function internalIntegrateLog(lhs: DecimalSource, rhs: DecimalSource) {
return Decimal.ln(lhs).sub(1).times(lhs).div(Decimal.ln(rhs));
}
function internalInvertIntegralLog(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
const numerator = Decimal.ln(unrefFormulaSource(rhs)).times(value);
return lhs.invert(numerator.div(numerator.div(Math.E).lambertw()));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateLog(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return new Formula({
inputs: [lhs.getIntegralFormula(stack), rhs],
evaluate: internalIntegrateLog,
invert: internalInvertIntegralLog
});
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertLog2(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.pow(2, value));
}
throw new Error("Could not invert due to no input being a variable");
}
function internalIntegrateLog2(lhs: DecimalSource) {
return Decimal.ln(lhs).sub(1).times(lhs).div(Decimal.ln(2));
}
function internalInvertIntegralLog2(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const numerator = Decimal.ln(2).times(value);
return lhs.invert(numerator.div(numerator.div(Math.E).lambertw()));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateLog2(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return new Formula({
inputs: [lhs.getIntegralFormula(stack)],
evaluate: internalIntegrateLog2,
invert: internalInvertIntegralLog2
});
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertLn(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.exp(value));
}
throw new Error("Could not invert due to no input being a variable");
}
function internalIntegrateLn(lhs: DecimalSource) {
return Decimal.ln(lhs).sub(1).times(lhs);
}
function internalInvertIntegralLn(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.div(value, Decimal.div(value, Math.E).lambertw()));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateLn(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return new Formula({
inputs: [lhs.getIntegralFormula(stack)],
evaluate: internalIntegrateLn,
invert: internalInvertIntegralLn
});
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertPow(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.root(value, unrefFormulaSource(rhs)));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.ln(value).div(Decimal.ln(unrefFormulaSource(lhs))));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integratePow(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
const pow = Formula.add(rhs, 1);
return Formula.pow(x, pow).div(pow);
} else if (hasVariable(rhs)) {
const x = rhs.getIntegralFormula(stack);
return Formula.pow(lhs, x).div(Formula.ln(lhs));
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertPow10(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.root(value, 10));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integratePow10(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.pow10(x).div(Formula.ln(10));
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertPowBase(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.ln(value).div(unrefFormulaSource(rhs)));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.root(unrefFormulaSource(lhs), value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integratePowBase(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.pow(rhs, x).div(Formula.ln(rhs));
} else if (hasVariable(rhs)) {
const x = rhs.getIntegralFormula(stack);
const denominator = Formula.add(lhs, 1);
return Formula.pow(x, denominator).div(denominator);
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertRoot(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.root(value, Decimal.recip(unrefFormulaSource(rhs))));
} else if (hasVariable(rhs)) {
return rhs.invert(Decimal.ln(unrefFormulaSource(lhs)).div(Decimal.ln(value)));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateRoot(stack: SubstitutionStack, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.pow(x, Formula.recip(rhs).add(1)).times(rhs).div(Formula.add(rhs, 1));
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertExp(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.ln(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateExp(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.exp(x);
}
throw new Error("Could not integrate due to no input being a variable");
}
export function tetrate(
value: DecimalSource,
height: DecimalSource = 2,
payload: DecimalSource = Decimal.fromComponents_noNormalize(1, 0, 1)
) {
const heightNumber = Decimal.minabs(height, 1e308).toNumber();
return Decimal.tetrate(value, heightNumber, payload);
}
export function invertTetrate(
value: DecimalSource,
base: FormulaSource,
height: FormulaSource,
payload: FormulaSource
) {
if (hasVariable(base)) {
return base.invert(Decimal.ssqrt(value));
}
// Other params can't be inverted ATM
throw new Error("Could not invert due to no input being a variable");
}
export function iteratedexp(
value: DecimalSource,
height: DecimalSource = 2,
payload: DecimalSource = Decimal.fromComponents_noNormalize(1, 0, 1)
) {
const heightNumber = Decimal.minabs(height, 1e308).toNumber();
return Decimal.iteratedexp(value, heightNumber, new Decimal(payload));
}
export function invertIteratedExp(
value: DecimalSource,
lhs: FormulaSource,
height: FormulaSource,
payload: FormulaSource
) {
if (hasVariable(lhs)) {
return lhs.invert(
Decimal.iteratedlog(
value,
Math.E,
Decimal.minabs(1e308, unrefFormulaSource(height)).toNumber()
)
);
}
// Other params can't be inverted ATM
throw new Error("Could not invert due to no input being a variable");
}
export function iteratedLog(
value: DecimalSource,
lhs: DecimalSource = 10,
times: DecimalSource = 2
) {
const timesNumber = Decimal.minabs(times, 1e308).toNumber();
return Decimal.iteratedlog(value, lhs, timesNumber);
}
export function slog(value: DecimalSource, lhs: DecimalSource = 10) {
const baseNumber = Decimal.minabs(lhs, 1e308).toNumber();
return Decimal.slog(value, baseNumber);
}
export function invertSlog(value: DecimalSource, lhs: FormulaSource, rhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(
Decimal.tetrate(value, Decimal.minabs(1e308, unrefFormulaSource(rhs)).toNumber())
);
}
// Other params can't be inverted ATM
throw new Error("Could not invert due to no input being a variable");
}
export function layeradd(value: DecimalSource, diff: DecimalSource, base: DecimalSource) {
const diffNumber = Decimal.minabs(diff, 1e308).toNumber();
return Decimal.layeradd(value, diffNumber, base);
}
export function invertLayeradd(
value: DecimalSource,
lhs: FormulaSource,
diff: FormulaSource,
base: FormulaSource
) {
if (hasVariable(lhs)) {
return lhs.invert(
Decimal.layeradd(
value,
Decimal.minabs(1e308, unrefFormulaSource(diff)).negate().toNumber()
)
);
}
// Other params can't be inverted ATM
throw new Error("Could not invert due to no input being a variable");
}
export function invertLambertw(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.pow(Math.E, value).times(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function invertSsqrt(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.tetrate(value, 2));
}
throw new Error("Could not invert due to no input being a variable");
}
export function pentate(value: DecimalSource, height: DecimalSource, payload: DecimalSource) {
const heightNumber = Decimal.minabs(height, 1e308).toNumber();
return Decimal.pentate(value, heightNumber, payload);
}
export function invertSin(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.asin(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateSin(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.cos(x).neg();
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertCos(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.acos(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateCos(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.sin(x);
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertTan(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.atan(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateTan(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.cos(x).ln().neg();
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertAsin(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.sin(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateAsin(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.asin(x)
.times(x)
.add(Formula.sqrt(Formula.sub(1, Formula.pow(x, 2))));
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertAcos(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.cos(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateAcos(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.acos(x)
.times(x)
.sub(Formula.sqrt(Formula.sub(1, Formula.pow(x, 2))));
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertAtan(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.tan(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateAtan(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.atan(x)
.times(x)
.sub(Formula.ln(Formula.pow(x, 2).add(1)).div(2));
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertSinh(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.asinh(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateSinh(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.cosh(x);
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertCosh(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.acosh(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateCosh(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.sinh(x);
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertTanh(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.atanh(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateTanh(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.cosh(x).ln();
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertAsinh(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.sinh(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateAsinh(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.asinh(x).times(x).sub(Formula.pow(x, 2).add(1).sqrt());
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertAcosh(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.cosh(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateAcosh(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.acosh(x)
.times(x)
.sub(Formula.add(x, 1).sqrt().times(Formula.sub(x, 1).sqrt()));
}
throw new Error("Could not integrate due to no input being a variable");
}
export function invertAtanh(value: DecimalSource, lhs: FormulaSource) {
if (hasVariable(lhs)) {
return lhs.invert(Decimal.tanh(value));
}
throw new Error("Could not invert due to no input being a variable");
}
export function integrateAtanh(stack: SubstitutionStack, lhs: FormulaSource) {
if (hasVariable(lhs)) {
const x = lhs.getIntegralFormula(stack);
return Formula.atanh(x)
.times(x)
.add(Formula.sub(1, Formula.pow(x, 2)).ln().div(2));
}
throw new Error("Could not integrate due to no input being a variable");
}
export function createPassthroughBinaryFormula(
operation: (a: DecimalSource, b: DecimalSource) => DecimalSource
) {
return (value: FormulaSource, other: FormulaSource) =>
new Formula({
inputs: [value, other],
evaluate: operation,
invert: passthrough as InvertFunction<[FormulaSource, FormulaSource]>
});
}

70
src/game/formulas/types.d.ts vendored Normal file
View file

@ -0,0 +1,70 @@
import Formula from "game/formulas/formulas";
import { DecimalSource } from "util/bignum";
import { ProcessedComputable } from "util/computed";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type GenericFormula = Formula<any>;
type FormulaSource = ProcessedComputable<DecimalSource> | GenericFormula;
type InvertibleFormula = GenericFormula & {
invert: (value: DecimalSource) => DecimalSource;
};
type IntegrableFormula = GenericFormula & {
evaluateIntegral: (variable?: DecimalSource) => DecimalSource;
};
type InvertibleIntegralFormula = GenericFormula & {
invertIntegral: (value: DecimalSource) => DecimalSource;
};
type EvaluateFunction<T> = (
this: Formula<T>,
...inputs: GuardedFormulasToDecimals<T>
) => DecimalSource;
type InvertFunction<T> = (this: Formula<T>, value: DecimalSource, ...inputs: T) => DecimalSource;
type IntegrateFunction<T> = (
this: Formula<T>,
stack: SubstitutionStack | undefined,
...inputs: T
) => GenericFormula;
type SubstitutionFunction<T> = (
this: Formula<T>,
variable: GenericFormula,
...inputs: T
) => GenericFormula;
type VariableFormulaOptions = { variable: ProcessedComputable<DecimalSource> };
type ConstantFormulaOptions = {
inputs: [FormulaSource];
};
type GeneralFormulaOptions<T extends [FormulaSource] | FormulaSource[]> = {
inputs: T;
evaluate: EvaluateFunction<T>;
invert?: InvertFunction<T>;
integrate?: IntegrateFunction<T>;
integrateInner?: IntegrateFunction<T>;
applySubstitution?: SubstitutionFunction<T>;
};
type FormulaOptions<T extends [FormulaSource] | FormulaSource[]> =
| VariableFormulaOptions
| ConstantFormulaOptions
| GeneralFormulaOptions<T>;
type InternalFormulaProperties<T extends [FormulaSource] | FormulaSource[]> = {
inputs: T;
internalVariables: number;
internalEvaluate?: EvaluateFunction<T>;
internalInvert?: InvertFunction<T>;
internalIntegrate?: IntegrateFunction<T>;
internalIntegrateInner?: IntegrateFunction<T>;
applySubstitution?: SubstitutionFunction<T>;
innermostVariable?: ProcessedComputable<DecimalSource>;
};
type SubstitutionStack = ((value: GenericFormula) => GenericFormula)[] | undefined;
// It's really hard to type mapped tuples, but these classes seem to manage
type FormulasToDecimals<T extends FormulaSource[]> = {
[K in keyof T]: DecimalSource;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TupleGuard<T extends any[]> = T extends any[] ? FormulasToDecimals<T> : never;
type GuardedFormulasToDecimals<T extends FormulaSource[]> = TupleGuard<T>;

View file

@ -115,7 +115,7 @@ function checkNaNAndWrite<T extends State>(persistent: Persistent<T>, value: T)
persistent[SaveDataPath]?.join("."),
persistent
);
throw "Attempted to set NaN value. See above for details";
throw new Error("Attempted to set NaN value. See above for details");
}
persistent[PersistentState].value = value;
}

View file

@ -1,5 +1,12 @@
import { isArray } from "@vue/shared";
import { CoercableComponent, isVisible, jsx, setDefault, Visibility } from "features/feature";
import {
CoercableComponent,
isVisible,
jsx,
Replace,
setDefault,
Visibility
} from "features/feature";
import { displayResource, Resource } from "features/resources/resource";
import Decimal, { DecimalSource } from "lib/break_eternity";
import {
@ -11,12 +18,8 @@ import {
import { createLazyProxy } from "util/proxies";
import { joinJSX, renderJSX } from "util/vue";
import { computed, unref } from "vue";
import Formula, {
calculateCost,
calculateMaxAffordable,
GenericFormula,
InvertibleFormula
} from "./formulas";
import Formula, { calculateCost, calculateMaxAffordable } from "./formulas/formulas";
import type { GenericFormula, InvertibleFormula } from "./formulas/types";
import { DefaultValue, Persistent } from "./persistence";
/**
@ -90,7 +93,15 @@ export interface CostRequirementOptions {
pay?: (amount?: DecimalSource) => void;
}
export type CostRequirement = Requirement & CostRequirementOptions;
export type CostRequirement = Replace<
Requirement & CostRequirementOptions,
{
cost: ProcessedComputable<DecimalSource> | GenericFormula;
visibility: ProcessedComputable<Visibility.Visible | Visibility.None | boolean>;
requiresPay: ProcessedComputable<boolean>;
spendResources: ProcessedComputable<boolean>;
}
>;
/**
* Lazily creates a requirement with the given options, that is based on meeting an amount of a resource.
@ -113,13 +124,7 @@ export function createCostRequirement<T extends CostRequirementOptions>(
{displayResource(
req.resource,
req.cost instanceof Formula
? calculateCost(
req.cost,
amount ?? 1,
unref(
req.spendResources as ProcessedComputable<boolean> | undefined
) ?? true
)
? calculateCost(req.cost, amount ?? 1, unref(req.spendResources) as boolean)
: unref(req.cost as ProcessedComputable<DecimalSource>)
)}{" "}
{req.resource.displayName}
@ -131,13 +136,7 @@ export function createCostRequirement<T extends CostRequirementOptions>(
{displayResource(
req.resource,
req.cost instanceof Formula
? calculateCost(
req.cost,
amount ?? 1,
unref(
req.spendResources as ProcessedComputable<boolean> | undefined
) ?? true
)
? calculateCost(req.cost, amount ?? 1, unref(req.spendResources) as boolean)
: unref(req.cost as ProcessedComputable<DecimalSource>)
)}{" "}
{req.resource.displayName}
@ -148,17 +147,13 @@ export function createCostRequirement<T extends CostRequirementOptions>(
setDefault(req, "visibility", Visibility.Visible);
processComputable(req as T, "cost");
processComputable(req as T, "requiresPay");
processComputable(req as T, "spendResources");
setDefault(req, "requiresPay", true);
processComputable(req as T, "spendResources");
setDefault(req, "spendResources", true);
setDefault(req, "pay", function (amount?: DecimalSource) {
const cost =
req.cost instanceof Formula
? calculateCost(
req.cost,
amount ?? 1,
unref(req.spendResources as ProcessedComputable<boolean> | undefined) ??
true
)
? calculateCost(req.cost, amount ?? 1, unref(req.spendResources) as boolean)
: unref(req.cost as ProcessedComputable<DecimalSource>);
req.resource.value = Decimal.sub(req.resource.value, cost).max(0);
});
@ -169,7 +164,7 @@ export function createCostRequirement<T extends CostRequirementOptions>(
req.requirementMet = calculateMaxAffordable(
req.cost as InvertibleFormula,
req.resource,
unref(req.spendResources as ProcessedComputable<boolean> | undefined) ?? true
unref(req.spendResources) as boolean
);
} else {
req.requirementMet = computed(() => {
@ -242,7 +237,7 @@ export function maxRequirementsMet(requirements: Requirements): DecimalSource {
}
const reqsMet = unref(requirements.requirementMet);
if (typeof reqsMet === "boolean") {
return reqsMet ? Infinity : 0;
return reqsMet ? Decimal.dInf : 0;
} else if (Decimal.gt(reqsMet, 1) && unref(requirements.canMaximize) !== true) {
return 1;
}

View file

@ -2788,7 +2788,7 @@ export default class Decimal {
return FC_NN(this.sign, this.layer - 1, this.mag);
}
throw "Unhandled behavior in lambertw()";
throw new Error("Unhandled behavior in lambertw()");
}
//The super square-root function - what number, tetrated to height 2, equals this?

View file

@ -26,7 +26,9 @@ declare global {
document.title = projInfo.title;
window.projInfo = projInfo;
if (projInfo.id === "") {
throw "Project ID is empty! Please select a unique ID for this project in /src/data/projInfo.json";
throw new Error(
"Project ID is empty! Please select a unique ID for this project in /src/data/projInfo.json"
);
}
requestAnimationFrame(async () => {

View file

@ -2,18 +2,18 @@ import { createResource, Resource } from "features/resources/resource";
import Formula, {
calculateCost,
calculateMaxAffordable,
GenericFormula,
InvertibleFormula,
printFormula,
unrefFormulaSource
} from "game/formulas";
import Decimal, { DecimalSource } from "util/bignum";
} from "game/formulas/formulas";
import type { GenericFormula, InvertibleFormula } from "game/formulas/types";
import Decimal, { DecimalSource, format } from "util/bignum";
import { beforeAll, describe, expect, test } from "vitest";
import { ref } from "vue";
import "../utils";
type FormulaFunctions = keyof GenericFormula & keyof typeof Formula & keyof typeof Decimal;
const testValues = ["-1e400", 0, 0.25] as const;
const testValues = [-1, "0", Decimal.dOne] as const;
const invertibleZeroParamFunctionNames = [
"neg",
@ -40,7 +40,10 @@ const invertibleZeroParamFunctionNames = [
"tanh",
"asinh",
"acosh",
"atanh"
"atanh",
"slog",
"tetrate",
"iteratedexp"
] as const;
const nonInvertibleZeroParamFunctionNames = [
"abs",
@ -94,21 +97,21 @@ const invertibleIntegralZeroPramFunctionNames = [
"sqr",
"sqrt",
"cube",
"cbrt"
] as const;
const nonInvertibleIntegralZeroPramFunctionNames = [
...nonIntegrableZeroParamFunctionNames,
"cbrt",
"neg",
"exp",
"sin",
"cos",
"tan",
"sinh",
"cosh",
"tanh"
] as const;
const nonInvertibleIntegralZeroPramFunctionNames = [
...nonIntegrableZeroParamFunctionNames,
"asin",
"acos",
"atan",
"sinh",
"cosh",
"tanh",
"asinh",
"acosh",
"atanh"
@ -122,7 +125,7 @@ const invertibleOneParamFunctionNames = [
"log",
"pow",
"root",
"slog"
"layeradd"
] as const;
const nonInvertibleOneParamFunctionNames = ["layeradd10"] as const;
const integrableOneParamFunctionNames = ["add", "sub", "mul", "div", "log", "pow", "root"] as const;
@ -130,21 +133,19 @@ const nonIntegrableOneParamFunctionNames = [...nonInvertibleOneParamFunctionName
const invertibleIntegralOneParamFunctionNames = integrableOneParamFunctionNames;
const nonInvertibleIntegralOneParamFunctionNames = nonIntegrableOneParamFunctionNames;
const invertibleTwoParamFunctionNames = ["tetrate", "layeradd", "iteratedexp"] as const;
const nonInvertibleTwoParamFunctionNames = ["iteratedlog", "pentate"] as const;
const nonIntegrableTwoParamFunctionNames = [
...invertibleTwoParamFunctionNames,
...nonInvertibleZeroParamFunctionNames
];
const nonIntegrableTwoParamFunctionNames = nonInvertibleTwoParamFunctionNames;
const nonInvertibleIntegralTwoParamFunctionNames = nonIntegrableTwoParamFunctionNames;
describe("Formula Equality Checking", () => {
describe("Equality Checks", () => {
test("Equals", () => Formula.add(1, 1).equals(Formula.add(1, 1)));
test("Not Equals due to inputs", () => Formula.add(1, 1).equals(Formula.add(1, 0)));
test("Not Equals due to functions", () => Formula.add(1, 1).equals(Formula.sub(1, 1)));
test("Equals", () => expect(Formula.add(1, 1).equals(Formula.add(1, 1))).toBe(true));
test("Not Equals due to inputs", () =>
expect(Formula.add(1, 1).equals(Formula.add(1, 0))).toBe(false));
test("Not Equals due to functions", () =>
expect(Formula.add(1, 1).equals(Formula.sub(1, 1))).toBe(false));
test("Not Equals due to hasVariable", () =>
Formula.constant(1).equals(Formula.variable(1)));
expect(Formula.constant(1).equals(Formula.variable(1))).toBe(false));
});
describe("Formula aliases", () => {
@ -201,16 +202,6 @@ describe("Formula Equality Checking", () => {
});
}
);
[...invertibleTwoParamFunctionNames, ...nonInvertibleTwoParamFunctionNames].forEach(
name => {
test(name, () => {
const instanceFormula = formula[name](1, 1);
const staticFormula = Formula[name](formula, 1, 1);
expect(instanceFormula.equals(staticFormula)).toBe(true);
});
}
);
});
});
@ -272,6 +263,11 @@ describe("Creating Formulas", () => {
functionName: T,
args: Readonly<Parameters<typeof Formula[T]>>
) {
if ((functionName === "slog" || functionName === "layeradd") && args[0] === -1) {
// These cases in particular take a long time, so skip them
// We still have plenty of coverage
return;
}
let testName = functionName + "(";
for (let i = 0; i < args.length; i++) {
if (i !== 0) {
@ -331,13 +327,7 @@ describe("Creating Formulas", () => {
);
});
describe("2-param", () => {
(
[
...invertibleTwoParamFunctionNames,
...nonInvertibleTwoParamFunctionNames,
"clamp"
] as const
).forEach(names =>
([...nonInvertibleTwoParamFunctionNames, "clamp"] as const).forEach(names =>
describe(names, () => {
checkFormula(names, [0, 0, 0] as const);
testValues.forEach(i =>
@ -403,24 +393,6 @@ describe("Inverting", () => {
checkFormula(Formula[name](variable, variable), false));
});
});
invertibleTwoParamFunctionNames.forEach(name => {
describe(name, () => {
test(`${name}(var, const, const) is marked as invertible and having a variable`, () =>
checkFormula(Formula[name](variable, constant, constant)));
test(`${name}(const, var, const) is marked as invertible and having a variable`, () =>
checkFormula(Formula[name](constant, variable, constant)));
test(`${name}(const, const, var) is marked as invertible and having a variable`, () =>
checkFormula(Formula[name](constant, constant, variable)));
test(`${name}(var, var, const) is marked as not invertible and not having a variable`, () =>
checkFormula(Formula[name](variable, variable, constant), false));
test(`${name}(var, const, var) is marked as not invertible and not having a variable`, () =>
checkFormula(Formula[name](variable, constant, variable), false));
test(`${name}(const, var, var) is marked as not invertible and not having a variable`, () =>
checkFormula(Formula[name](constant, variable, variable), false));
test(`${name}(var, var, var) is marked as not invertible and not having a variable`, () =>
checkFormula(Formula[name](variable, variable, variable), false));
});
});
});
describe("Non-invertible formulas marked as such", () => {
@ -479,30 +451,13 @@ describe("Inverting", () => {
const result = formula.evaluate();
expect(formula.invert(result)).compare_tolerance(2);
});
if (name !== "layeradd") {
test(`${name}(const, var).invert()`, () => {
const formula = Formula[name](constant, variable);
const result = formula.evaluate();
expect(formula.invert(result)).compare_tolerance(2);
});
})
);
invertibleTwoParamFunctionNames.forEach(name =>
describe(name, () => {
test(`${name}(var, const, const).invert()`, () => {
const formula = Formula[name](variable, constant, constant);
const result = formula.evaluate();
expect(formula.invert(result)).compare_tolerance(2);
});
test(`${name}(const, var, const).invert()`, () => {
const formula = Formula[name](constant, variable, constant);
const result = formula.evaluate();
expect(formula.invert(result)).compare_tolerance(2);
});
test(`${name}(const, const, var).invert()`, () => {
const formula = Formula[name](constant, constant, variable);
const result = formula.evaluate();
expect(formula.invert(result)).compare_tolerance(2);
});
}
})
);
});
@ -530,7 +485,7 @@ describe("Inverting", () => {
test("Inverting with non-invertible sections", () => {
const formula = Formula.add(variable, constant.ceil());
expect(formula.isInvertible()).toBe(true);
expect(formula.invert(10)).compare_tolerance(7);
expect(formula.invert(10)).compare_tolerance(0);
});
});
@ -538,14 +493,14 @@ describe("Integrating", () => {
let variable: GenericFormula;
let constant: GenericFormula;
beforeAll(() => {
variable = Formula.variable(10);
variable = Formula.variable(ref(10));
constant = Formula.constant(10);
});
test("evaluateIntegral() returns variable's value", () =>
expect(variable.evaluate()).compare_tolerance(10));
test("evaluateIntegral(variable) overrides variable value", () =>
expect(variable.add(10).evaluateIntegral(20)).compare_tolerance(400));
test("variable.evaluateIntegral() calculates correctly", () =>
expect(variable.evaluateIntegral()).compare_tolerance(Decimal.pow(10, 2).div(2)));
test("variable.evaluateIntegral(variable) overrides variable value", () =>
expect(variable.evaluateIntegral(20)).compare_tolerance(Decimal.pow(20, 2).div(2)));
describe("Integrable functions marked as such", () => {
function checkFormula(formula: GenericFormula) {
@ -562,8 +517,10 @@ describe("Integrating", () => {
describe(name, () => {
test(`${name}(var, const) is marked as integrable`, () =>
checkFormula(Formula[name](variable, constant)));
if (name !== "log" && name !== "root") {
test(`${name}(const, var) is marked as integrable`, () =>
checkFormula(Formula[name](constant, variable)));
}
test(`${name}(var, var) is marked as not integrable`, () =>
expect(Formula[name](variable, variable).isIntegrable()).toBe(false));
});
@ -614,8 +571,44 @@ describe("Integrating", () => {
describe.todo("Integrable formulas integrate correctly");
test("Integrating nested formulas", () => {
const formula = Formula.add(variable, constant).times(constant);
expect(formula.evaluateIntegral()).compare_tolerance(1500);
const formula = Formula.add(variable, constant).times(constant).pow(2).times(30).add(10);
const actualCost = new Array(10)
.fill(null)
.reduce((acc, _, i) => acc.add(formula.evaluate(i)), new Decimal(0));
// Check if the calculated cost is within 10% of the actual cost,
// because this is an approximation
expect(
Decimal.sub(
actualCost,
Decimal.add(formula.evaluateIntegral(), formula.calculateConstantOfIntegration())
)
.abs()
.div(actualCost)
.toNumber()
).toBeLessThan(0.1);
});
test("Integrating nested formulas with overidden variable", () => {
const formula = Formula.add(variable, constant).times(constant).pow(2).times(30).add(10);
const actualCost = new Array(20)
.fill(null)
.reduce((acc, _, i) => acc.add(formula.evaluate(i)), new Decimal(0));
// Check if the calculated cost is within 10% of the actual cost,
// because this is an approximation
expect(
Decimal.sub(
actualCost,
Decimal.add(formula.evaluateIntegral(20), formula.calculateConstantOfIntegration())
)
.abs()
.div(actualCost)
.toNumber()
).toBeLessThan(0.1);
});
test("Integrating nested complex formulas", () => {
const formula = Formula.pow(1.05, variable).times(100).pow(0.5);
expect(() => formula.evaluateIntegral()).toThrow();
});
});
@ -627,8 +620,10 @@ describe("Inverting integrals", () => {
constant = Formula.constant(10);
});
test("variable.invertIntegral() is pass-through", () =>
expect(variable.invertIntegral(20)).compare_tolerance(20));
test("variable.invertIntegral() calculates correctly", () =>
expect(variable.invertIntegral(20)).compare_tolerance(
Decimal.sqrt(20).times(Decimal.sqrt(2))
));
describe("Invertible Integral functions marked as such", () => {
function checkFormula(formula: GenericFormula) {
@ -645,8 +640,10 @@ describe("Inverting integrals", () => {
describe(name, () => {
test(`${name}(var, const) is marked as having an invertible integral`, () =>
checkFormula(Formula[name](variable, constant)));
if (name !== "log" && name !== "root") {
test(`${name}(const, var) is marked as having an invertible integral`, () =>
checkFormula(Formula[name](constant, variable)));
}
test(`${name}(var, var) is marked as not having an invertible integral`, () => {
const formula = Formula[name](variable, variable);
expect(formula.isIntegralInvertible()).toBe(false);
@ -700,27 +697,13 @@ describe("Inverting integrals", () => {
describe.todo("Invertible Integral formulas invert correctly");
test("Inverting integral of nested formulas", () => {
const formula = Formula.add(variable, constant).times(constant);
expect(formula.invertIntegral(1500)).compare_tolerance(10);
const formula = Formula.add(variable, constant).times(constant).pow(2).times(30);
expect(formula.invertIntegral(formula.evaluateIntegral())).compare_tolerance(10);
});
describe("Inverting integral pass-throughs", () => {
test("max", () =>
expect(Formula.max(variable, constant).invertIntegral(10)).compare_tolerance(10));
test("min", () =>
expect(Formula.min(variable, constant).invertIntegral(10)).compare_tolerance(10));
test("minabs", () =>
expect(Formula.minabs(variable, constant).invertIntegral(10)).compare_tolerance(10));
test("maxabs", () =>
expect(Formula.maxabs(variable, constant).invertIntegral(10)).compare_tolerance(10));
test("clampMax", () =>
expect(Formula.clampMax(variable, constant).invertIntegral(10)).compare_tolerance(10));
test("clampMin", () =>
expect(Formula.clampMin(variable, constant).invertIntegral(10)).compare_tolerance(10));
test("clamp", () =>
expect(
Formula.clamp(variable, constant, constant).invertIntegral(10)
).compare_tolerance(10));
test("Inverting integral of nested complex formulas", () => {
const formula = Formula.pow(1.05, variable).times(100).pow(0.5);
expect(() => formula.invertIntegral(100)).toThrow();
});
});
@ -785,6 +768,17 @@ describe("Step-wise", () => {
).compare_tolerance(10));
});
describe("Pass-through at boundary", () => {
test("Evaluates correctly", () =>
expect(
Formula.step(constant, 10, value => Formula.sqrt(value)).evaluate()
).compare_tolerance(10));
test("Inverts correctly with variable in input", () =>
expect(
Formula.step(variable, 10, value => Formula.sqrt(value)).invert(10)
).compare_tolerance(10));
});
describe("Evaluates correctly beyond start", () => {
test("Evaluates correctly", () =>
expect(
@ -795,6 +789,23 @@ describe("Step-wise", () => {
Formula.step(variable, 8, value => Formula.add(value, 2)).invert(12)
).compare_tolerance(10));
});
describe("Evaluates correctly when nested", () => {
test("Evaluates correctly", () =>
expect(
Formula.add(variable, constant)
.step(10, value => Formula.mul(value, 2))
.sub(10)
.evaluate()
).compare_tolerance(20));
test("Inverts correctly", () =>
expect(
Formula.add(variable, constant)
.step(10, value => Formula.mul(value, 2))
.sub(10)
.invert(30)
).compare_tolerance(15));
});
});
describe("Conditionals", () => {
@ -868,9 +879,31 @@ describe("Conditionals", () => {
Formula.if(variable, true, value => Formula.add(value, 2)).invert(12)
).compare_tolerance(10));
});
describe("Evaluates correctly when nested", () => {
test("Evaluates correctly", () =>
expect(
Formula.add(variable, constant)
.if(true, value => Formula.add(value, 2))
.div(2)
.evaluate()
).compare_tolerance(11));
test("Inverts correctly", () =>
expect(
Formula.add(variable, constant)
.if(true, value => Formula.add(value, 2))
.div(2)
.invert(12)
).compare_tolerance(12));
});
});
describe("Custom Formulas", () => {
let variable: GenericFormula;
beforeAll(() => {
variable = Formula.variable(1);
});
describe("Formula with evaluate", () => {
test("Zero input evaluates correctly", () =>
expect(new Formula({ inputs: [], evaluate: () => 10 }).evaluate()).compare_tolerance(
@ -887,126 +920,251 @@ describe("Custom Formulas", () => {
});
describe("Formula with invert", () => {
test("Zero input inverts correctly", () =>
expect(
test("Zero input does not invert", () =>
expect(() =>
new Formula({
inputs: [],
evaluate: () => 6,
invert: value => value,
hasVariable: true
invert: value => value
}).invert(10)
).compare_tolerance(10));
).toThrow());
test("One input inverts correctly", () =>
expect(
new Formula({
inputs: [1],
inputs: [variable],
evaluate: () => 10,
invert: (value, v1) => v1,
hasVariable: true
invert: (value, v1) => v1.evaluate()
}).invert(10)
).compare_tolerance(1));
test("Two inputs inverts correctly", () =>
expect(
new Formula({
inputs: [1, 2],
inputs: [variable, 2],
evaluate: () => 10,
invert: (value, v1, v2) => v2,
hasVariable: true
invert: (value, v1, v2) => v2
}).invert(10)
).compare_tolerance(2));
});
describe("Formula with integrate", () => {
test("Zero input integrates correctly", () =>
expect(
test("Zero input cannot integrate", () =>
expect(() =>
new Formula({
inputs: [],
evaluate: () => 10,
integrate: () => 20
evaluate: () => 0,
integrate: stack => variable
}).evaluateIntegral()
).compare_tolerance(20));
).toThrow());
test("One input integrates correctly", () =>
expect(
new Formula({
inputs: [1],
evaluate: () => 10,
integrate: (val, v1) => val ?? 20
inputs: [variable],
evaluate: v1 => Decimal.add(v1, 10),
integrate: (stack, v1) => Formula.add(v1, 10)
}).evaluateIntegral()
).compare_tolerance(20));
).compare_tolerance(11));
test("Two inputs integrates correctly", () =>
expect(
new Formula({
inputs: [1, 2],
evaluate: (v1, v2) => 10,
integrate: (v1, v2) => 3
inputs: [variable, 10],
evaluate: (v1, v2) => Decimal.add(v1, v2),
integrate: (stack, v1, v2) => Formula.add(v1, v2)
}).evaluateIntegral()
).compare_tolerance(3));
).compare_tolerance(11));
});
describe("Formula with invertIntegral", () => {
test("Zero input inverts integral correctly", () =>
expect(
test("Zero input does not invert integral", () =>
expect(() =>
new Formula({
inputs: [],
evaluate: () => 10,
invertIntegral: () => 1,
hasVariable: true
}).invertIntegral(8)
).compare_tolerance(1));
evaluate: () => 0,
integrate: stack => variable
}).invertIntegral(20)
).toThrow());
test("One input inverts integral correctly", () =>
expect(
new Formula({
inputs: [1],
evaluate: () => 10,
invertIntegral: (val, v1) => 1,
hasVariable: true
}).invertIntegral(8)
).compare_tolerance(1));
inputs: [variable],
evaluate: v1 => Decimal.add(v1, 10),
integrate: (stack, v1) => Formula.add(v1, 10)
}).invertIntegral(20)
).compare_tolerance(10));
test("Two inputs inverts integral correctly", () =>
expect(
new Formula({
inputs: [1, 2],
evaluate: (v1, v2) => 10,
invertIntegral: (v1, v2) => 1,
hasVariable: true
}).invertIntegral(8)
).compare_tolerance(1));
inputs: [variable, 10],
evaluate: (v1, v2) => Decimal.add(v1, v2),
integrate: (stack, v1, v2) => Formula.add(v1, v2)
}).invertIntegral(20)
).compare_tolerance(10));
});
describe("Formula as input", () => {
let customFormula: GenericFormula;
beforeAll(() => {
customFormula = new Formula({
inputs: [variable],
evaluate: v1 => v1,
invert: value => value,
integrate: (stack, v1) => v1.getIntegralFormula(stack)
});
});
test("Evaluate correctly", () =>
expect(customFormula.add(10).evaluate()).compare_tolerance(11));
test("Invert correctly", () =>
expect(customFormula.add(10).invert(20)).compare_tolerance(10));
test("Integrate correctly", () =>
expect(customFormula.add(10).evaluateIntegral(10)).compare_tolerance(20));
});
});
describe("Buy Max", () => {
let resource: Resource;
beforeAll(() => {
resource = createResource(ref(10));
resource = createResource(ref(100000));
});
describe("With spending", () => {
describe("Without spending", () => {
test("Throws on formula with non-invertible integral", () => {
const maxAffordable = calculateMaxAffordable(Formula.neg(10), resource, false);
expect(() => maxAffordable.value).toThrow();
});
// https://www.desmos.com/calculator/5vgletdc1p
test("Calculates max affordable and cost correctly", () => {
const variable = Formula.variable(10);
const formula = Formula.pow(1.05, variable);
const variable = Formula.variable(0);
const formula = Formula.pow(1.05, variable).times(100);
const maxAffordable = calculateMaxAffordable(formula, resource, false);
expect(maxAffordable.value).compare_tolerance(47);
expect(maxAffordable.value).compare_tolerance(141);
expect(calculateCost(formula, maxAffordable.value, false)).compare_tolerance(
Decimal.pow(1.05, 47)
Decimal.pow(1.05, 141).times(100)
);
});
});
describe("Without spending", () => {
describe("With spending", () => {
test("Throws on non-invertible formula", () => {
const maxAffordable = calculateMaxAffordable(Formula.abs(10), resource);
expect(() => maxAffordable.value).toThrow();
});
// https://www.desmos.com/calculator/5vgletdc1p
test("Calculates max affordable and cost correctly", () => {
const variable = Formula.variable(10);
const formula = Formula.pow(1.05, variable);
test("Estimates max affordable and cost correctly with 0 purchases", () => {
const purchases = ref(0);
const variable = Formula.variable(purchases);
const formula = Formula.pow(1.05, variable).times(100);
const maxAffordable = calculateMaxAffordable(formula, resource, true, 0);
let actualAffordable = 0;
let summedCost = Decimal.dZero;
while (true) {
const nextCost = formula.evaluate(actualAffordable);
if (Decimal.add(summedCost, nextCost).lte(resource.value)) {
actualAffordable++;
summedCost = Decimal.add(summedCost, nextCost);
} else {
break;
}
}
expect(maxAffordable.value).compare_tolerance(actualAffordable);
const actualCost = new Array(actualAffordable)
.fill(null)
.reduce((acc, _, i) => acc.add(formula.evaluate(i)), new Decimal(0));
const calculatedCost = calculateCost(formula, maxAffordable.value);
// Check if the calculated cost is within 10% of the actual cost,
// because this is an approximation
expect(
Decimal.sub(actualCost, calculatedCost).abs().div(actualCost).toNumber()
).toBeLessThan(0.1);
});
test("Estimates max affordable and cost with 1 purchase", () => {
const purchases = ref(1);
const variable = Formula.variable(purchases);
const formula = Formula.pow(1.05, variable).times(100);
const maxAffordable = calculateMaxAffordable(formula, resource, true, 0);
let actualAffordable = 0;
let summedCost = Decimal.dZero;
while (true) {
const nextCost = formula.evaluate(Decimal.add(actualAffordable, 1));
if (Decimal.add(summedCost, nextCost).lte(resource.value)) {
actualAffordable++;
summedCost = Decimal.add(summedCost, nextCost);
} else {
break;
}
}
expect(maxAffordable.value).compare_tolerance(actualAffordable);
const actualCost = new Array(actualAffordable)
.fill(null)
.reduce((acc, _, i) => acc.add(formula.evaluate(i + 1)), new Decimal(0));
const calculatedCost = calculateCost(formula, maxAffordable.value);
// Check if the calculated cost is within 10% of the actual cost,
// because this is an approximation
expect(
Decimal.sub(actualCost, calculatedCost).abs().div(actualCost).toNumber()
).toBeLessThan(0.1);
});
test("Estimates max affordable and cost more accurately with summing last purchases", () => {
const purchases = ref(1);
const variable = Formula.variable(purchases);
const formula = Formula.pow(1.05, variable).times(100);
const maxAffordable = calculateMaxAffordable(formula, resource);
expect(maxAffordable.value).compare_tolerance(7);
expect(calculateCost(formula, maxAffordable.value)).compare_tolerance(7.35);
let actualAffordable = 0;
let summedCost = Decimal.dZero;
while (true) {
const nextCost = formula.evaluate(Decimal.add(actualAffordable, 1));
if (Decimal.add(summedCost, nextCost).lte(resource.value)) {
actualAffordable++;
summedCost = Decimal.add(summedCost, nextCost);
} else {
break;
}
}
expect(maxAffordable.value).compare_tolerance(actualAffordable);
const actualCost = new Array(actualAffordable)
.fill(null)
.reduce((acc, _, i) => acc.add(formula.evaluate(i + 1)), new Decimal(0));
const calculatedCost = calculateCost(formula, maxAffordable.value);
// Since we're summing the last few purchases, this has a tighter deviation allowed
expect(
Decimal.sub(actualCost, calculatedCost).abs().div(actualCost).toNumber()
).toBeLessThan(0.02);
});
test("Handles summing purchases when making few purchases", () => {
const purchases = ref(90);
const variable = Formula.variable(purchases);
const formula = Formula.pow(1.05, variable).times(100);
const maxAffordable = calculateMaxAffordable(formula, resource);
let actualAffordable = 0;
let summedCost = Decimal.dZero;
while (true) {
const nextCost = formula.evaluate(Decimal.add(actualAffordable, purchases.value));
if (Decimal.add(summedCost, nextCost).lte(resource.value)) {
actualAffordable++;
summedCost = Decimal.add(summedCost, nextCost);
} else {
break;
}
}
expect(maxAffordable.value).compare_tolerance(actualAffordable);
const actualCost = new Array(actualAffordable)
.fill(null)
.reduce(
(acc, _, i) => acc.add(formula.evaluate(i + purchases.value)),
new Decimal(0)
);
const calculatedCost = calculateCost(formula, maxAffordable.value);
// Since we're summing all the purchases this should be equivalent
expect(calculatedCost).compare_tolerance(actualCost);
});
test("Handles summing purchases when over e308 purchases", () => {
resource.value = "1ee308";
const purchases = ref(0);
const variable = Formula.variable(purchases);
const formula = variable;
const maxAffordable = calculateMaxAffordable(formula, resource);
const calculatedCost = calculateCost(formula, maxAffordable.value);
expect(Decimal.isNaN(calculatedCost)).toBe(false);
expect(Decimal.isFinite(calculatedCost)).toBe(true);
resource.value = 100000;
});
});
});

View file

@ -1,7 +1,8 @@
import { Visibility } from "features/feature";
import { createResource, Resource } from "features/resources/resource";
import Formula from "game/formulas";
import Formula from "game/formulas/formulas";
import {
CostRequirement,
createBooleanRequirement,
createCostRequirement,
createVisibilityRequirement,
@ -17,19 +18,18 @@ import "../utils";
describe("Creating cost requirement", () => {
describe("Minimal requirement", () => {
let resource: Resource;
let requirement: Requirement;
let requirement: CostRequirement;
beforeAll(() => {
resource = createResource(ref(10));
requirement = createCostRequirement(() => ({
resource,
cost: 10
cost: 10,
spendResources: false
}));
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
test("resource pass-through", () => expect((requirement as any).resource).toBe(resource));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
test("cost pass-through", () => expect((requirement as any).cost).toBe(10));
test("resource pass-through", () => expect(requirement.resource).toBe(resource));
test("cost pass-through", () => expect(requirement.cost).toBe(10));
test("partialDisplay exists", () =>
expect(typeof requirement.partialDisplay).toBe("function"));
@ -41,23 +41,22 @@ describe("Creating cost requirement", () => {
});
test("is visible", () => expect(requirement.visibility).toBe(Visibility.Visible));
test("requires pay", () => expect(requirement.requiresPay).toBe(true));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
test("spends resources", () => expect((requirement as any).spendResources).toBe(true));
test("does not spend resources", () => expect(requirement.spendResources).toBe(false));
test("cannot maximize", () => expect(unref(requirement.canMaximize)).toBe(false));
});
describe("Fully customized", () => {
let resource: Resource;
let requirement: Requirement;
let requirement: CostRequirement;
beforeAll(() => {
resource = createResource(ref(10));
requirement = createCostRequirement(() => ({
resource,
cost: 10,
cost: Formula.variable(resource).times(10),
visibility: Visibility.None,
requiresPay: false,
maximize: true,
spendResources: false,
spendResources: true,
// eslint-disable-next-line @typescript-eslint/no-empty-function
pay() {}
}));
@ -69,9 +68,7 @@ describe("Creating cost requirement", () => {
requirement.pay.length === 1);
test("is not visible", () => expect(requirement.visibility).toBe(Visibility.None));
test("does not require pay", () => expect(requirement.requiresPay).toBe(false));
test("does not spend resources", () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((requirement as any).spendResources).toBe(false));
test("spends resources", () => expect(requirement.spendResources).toBe(true));
test("can maximize", () => expect(unref(requirement.canMaximize)).toBe(true));
});
@ -79,7 +76,8 @@ describe("Creating cost requirement", () => {
const resource = createResource(ref(10));
const requirement = createCostRequirement(() => ({
resource,
cost: 10
cost: 10,
spendResources: false
}));
expect(unref(requirement.requirementMet)).toBe(true);
});
@ -88,7 +86,8 @@ describe("Creating cost requirement", () => {
const resource = createResource(ref(10));
const requirement = createCostRequirement(() => ({
resource,
cost: 100
cost: 100,
spendResources: false
}));
expect(unref(requirement.requirementMet)).toBe(false);
});
@ -148,7 +147,8 @@ describe("Checking maximum levels of requirements met", () => {
createBooleanRequirement(true),
createCostRequirement(() => ({
resource: createResource(ref(10)),
cost: Formula.variable(0)
cost: Formula.variable(0),
spendResources: false
}))
];
expect(maxRequirementsMet(requirements)).compare_tolerance(0);
@ -159,7 +159,8 @@ describe("Checking maximum levels of requirements met", () => {
createBooleanRequirement(true),
createCostRequirement(() => ({
resource: createResource(ref(10)),
cost: Formula.variable(0)
cost: Formula.variable(0),
spendResources: false
}))
];
expect(maxRequirementsMet(requirements)).compare_tolerance(10);
@ -171,11 +172,13 @@ test("Paying requirements", () => {
const noPayment = createCostRequirement(() => ({
resource,
cost: 10,
requiresPay: false
requiresPay: false,
spendResources: false
}));
const payment = createCostRequirement(() => ({
resource,
cost: 10
cost: 10,
spendResources: false
}));
payRequirements([noPayment, payment]);
expect(resource.value).compare_tolerance(90);

View file

@ -2,7 +2,7 @@ import Decimal, { DecimalSource, format } from "util/bignum";
import { expect } from "vitest";
interface CustomMatchers<R = unknown> {
compare_tolerance(expected: DecimalSource): R;
compare_tolerance(expected: DecimalSource, tolerance?: number): R;
}
declare global {
@ -16,7 +16,7 @@ declare global {
}
expect.extend({
compare_tolerance(received: DecimalSource, expected: DecimalSource) {
compare_tolerance(received: DecimalSource, expected: DecimalSource, tolerance?: number) {
const { isNot } = this;
let pass = false;
if (!Decimal.isFinite(expected)) {
@ -24,7 +24,7 @@ expect.extend({
} else if (Decimal.isNaN(expected)) {
pass = Decimal.isNaN(received);
} else {
pass = Decimal.eq_tolerance(received, expected);
pass = Decimal.eq_tolerance(received, expected, tolerance);
}
return {
// do not alter your "pass" based on isNot. Vitest does it for you