diff --git a/api/post-day-results.js b/api/post-day-results.js index ed5ae8c1b..e26e9fd42 100644 --- a/api/post-day-results.js +++ b/api/post-day-results.js @@ -15,6 +15,7 @@ const { getRoomData, getRoomName, } = require('../api-etc/utils') +const { random } = require('../src/common/utils') const client = getRedisClient() @@ -30,7 +31,7 @@ const applyPositionsToMarket = (valueAdjustments, positions) => { (acc, itemName) => { const itemPositionChange = positions[itemName] - const variance = Math.random() * 0.2 + const variance = random() * 0.2 const MAX = 1.5 const MIN = 0.5 @@ -43,7 +44,7 @@ const applyPositionsToMarket = (valueAdjustments, positions) => { // If item value is at a range boundary but was not changed in this // operation, randomize it to introduce some variability to the market. if (acc[itemName] === MAX || acc[itemName] === MIN) { - acc[itemName] = Math.random() + MIN + acc[itemName] = random() + MIN } } diff --git a/package-lock.json b/package-lock.json index ae770e4ca..7e73f05d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "react-use": "^17.4.0", "react-zoom-pan-pinch": "^1.6.1", "redis": "^3.0.2", + "seedrandom": "^3.0.5", "shifty": "^2.15.2", "source-map-explorer": "^2.3.1", "stream": "npm:stream-browserify@^3.0.0", @@ -80,6 +81,7 @@ "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^12.0.0", "@testing-library/user-event": "^13.2.1", + "@types/react": "^17.0.2", "@typescript-eslint/eslint-plugin": "^2.23.0", "@typescript-eslint/parser": "^2.23.0", "bittorrent-tracker": "^9.19.0", @@ -6780,11 +6782,11 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "17.0.52", - "license": "MIT", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.2.tgz", + "integrity": "sha512-Xt40xQsrkdvjn1EyWe1Bc0dJLcil/9x2vAuW7ya+PuQip4UYUaXyhzWmAbwRsdMgwOFHpfp7/FFZebDU6Y8VHA==", "dependencies": { "@types/prop-types": "*", - "@types/scheduler": "*", "csstype": "^3.0.2" } }, @@ -6796,6 +6798,23 @@ "@types/react": "^17" } }, + "node_modules/@types/react-dom/node_modules/@types/react": { + "version": "17.0.53", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.53.tgz", + "integrity": "sha512-1yIpQR2zdYu1Z/dc1OxC+MA6GR240u3gcnP4l6mvj/PJiVaqHsQPmWttsvHsfnhfPbU2FuGmo0wSITPygjBmsw==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom/node_modules/csstype": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", + "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==", + "dev": true + }, "node_modules/@types/react-transition-group": { "version": "4.4.5", "license": "MIT", @@ -6830,6 +6849,7 @@ }, "node_modules/@types/scheduler": { "version": "0.16.2", + "dev": true, "license": "MIT" }, "node_modules/@types/semver": { @@ -28720,6 +28740,11 @@ "version": "2.0.2", "license": "MIT" }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" + }, "node_modules/select-hose": { "version": "2.0.0", "dev": true, @@ -37777,10 +37802,11 @@ "dev": true }, "@types/react": { - "version": "17.0.52", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.2.tgz", + "integrity": "sha512-Xt40xQsrkdvjn1EyWe1Bc0dJLcil/9x2vAuW7ya+PuQip4UYUaXyhzWmAbwRsdMgwOFHpfp7/FFZebDU6Y8VHA==", "requires": { "@types/prop-types": "*", - "@types/scheduler": "*", "csstype": "^3.0.2" }, "dependencies": { @@ -37794,6 +37820,25 @@ "dev": true, "requires": { "@types/react": "^17" + }, + "dependencies": { + "@types/react": { + "version": "17.0.53", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.53.tgz", + "integrity": "sha512-1yIpQR2zdYu1Z/dc1OxC+MA6GR240u3gcnP4l6mvj/PJiVaqHsQPmWttsvHsfnhfPbU2FuGmo0wSITPygjBmsw==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "csstype": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", + "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==", + "dev": true + } } }, "@types/react-transition-group": { @@ -37822,7 +37867,8 @@ "version": "0.12.0" }, "@types/scheduler": { - "version": "0.16.2" + "version": "0.16.2", + "dev": true }, "@types/semver": { "version": "7.3.13" @@ -52460,6 +52506,11 @@ } } }, + "seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==" + }, "select-hose": { "version": "2.0.0", "dev": true diff --git a/package.json b/package.json index fb0c97927..e86b3fcf5 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^12.0.0", "@testing-library/user-event": "^13.2.1", + "@types/react": "^17.0.2", "@typescript-eslint/eslint-plugin": "^2.23.0", "@typescript-eslint/parser": "^2.23.0", "bittorrent-tracker": "^9.19.0", @@ -122,6 +123,7 @@ "react-use": "^17.4.0", "react-zoom-pan-pinch": "^1.6.1", "redis": "^3.0.2", + "seedrandom": "^3.0.5", "shifty": "^2.15.2", "source-map-explorer": "^2.3.1", "stream": "npm:stream-browserify@^3.0.0", diff --git a/src/common/services/randomNumber.js b/src/common/services/randomNumber.js new file mode 100644 index 000000000..eeccbedcd --- /dev/null +++ b/src/common/services/randomNumber.js @@ -0,0 +1,40 @@ +import seedrandom from 'seedrandom' +import window from 'global/window' + +export class RandomNumberService { + /** + * @type {Function?} + */ + seededRandom = null + + constructor() { + // The availability of window.location needs to be checked before accessing + // its .search property. This code runs in both a browser and Node.js + // context, and window.location is not defined in Node.js environments. + const initialSeed = new URLSearchParams(window.location?.search).get('seed') + + if (initialSeed) { + this.seedRandomNumber(initialSeed) + } + } + + /** + * @param {string} seed + */ + seedRandomNumber(seed) { + this.seededRandom = seedrandom(seed) + } + + /** + * @returns {number} + */ + generateRandomNumber() { + return this.seededRandom ? this.seededRandom() : Math.random() + } + + unseedRandomNumber() { + this.seededRandom = null + } +} + +export const randomNumberService = new RandomNumberService() diff --git a/src/common/utils.js b/src/common/utils.js index 2eb9e9575..2cc3534c3 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -1,5 +1,12 @@ +/** @typedef {import("../index").farmhand.priceEvent} farmhand.priceEvent */ import { itemsMap } from '../data/maps' +import { randomNumberService } from './services/randomNumber' + +export const random = () => { + return randomNumberService.generateRandomNumber() +} + /** * @param {farmhand.priceEvent} [priceCrashes] * @param {farmhand.priceEvent} [priceSurges] @@ -13,7 +20,7 @@ export const generateValueAdjustments = (priceCrashes = {}, priceSurges = {}) => } else if (priceSurges[key]) { acc[key] = 1.5 } else { - acc[key] = Math.random() + 0.5 + acc[key] = random() + 0.5 } } diff --git a/src/components/CowPen/CowPen.js b/src/components/CowPen/CowPen.js index 43bf35808..4a6aa5579 100644 --- a/src/components/CowPen/CowPen.js +++ b/src/components/CowPen/CowPen.js @@ -13,9 +13,10 @@ import { pixel } from '../../img' import { getCowDisplayName, getCowImage } from '../../utils' import './CowPen.sass' +import { random } from '../../common/utils' // Only moves the cow within the middle 80% of the pen -const randomPosition = () => 10 + Math.random() * 80 +const randomPosition = () => 10 + random() * 80 // TODO: Break this out into its own component file export class Cow extends Component { @@ -155,7 +156,7 @@ export class Cow extends Component { this.repositionTimeoutId = setTimeout( this.repositionTimeoutHandler, - Math.random() * this.waitVariance + random() * this.waitVariance ) } diff --git a/src/components/Home/SnowBackground.js b/src/components/Home/SnowBackground.js index 8e58efdbc..cea506ead 100644 --- a/src/components/Home/SnowBackground.js +++ b/src/components/Home/SnowBackground.js @@ -2,8 +2,10 @@ import React from 'react' import useWindowSize from 'react-use/lib/useWindowSize' import Confetti from 'react-confetti' +import { random } from '../../common/utils' + const randomInt = (min, max) => { - return Math.floor(min + Math.random() * (max - min + 1)) + return Math.floor(min + random() * (max - min + 1)) } // Taken from: diff --git a/src/components/SettingsView/RandomSeedInput.js b/src/components/SettingsView/RandomSeedInput.js new file mode 100644 index 000000000..f1a4e2ac7 --- /dev/null +++ b/src/components/SettingsView/RandomSeedInput.js @@ -0,0 +1,52 @@ +import React, { useContext, useState } from 'react' +import { string } from 'prop-types' +import window from 'global/window' +import TextField from '@material-ui/core/TextField' + +import FarmhandContext from '../Farmhand/Farmhand.context' + +import './RandomSeedInput.sass' + +export const RandomSeedInput = ({ search = window.location.search }) => { + const { + handlers: { handleRNGSeedChange }, + } = useContext(FarmhandContext) + + const [seed, setSeed] = useState( + new URLSearchParams(search).get('seed') ?? '' + ) + + /** + * @param {React.SyntheticEvent} e + */ + const handleChange = e => { + setSeed(e.target.value) + } + + /** + * @param {React.SyntheticEvent} e + */ + const handleSubmit = e => { + e.preventDefault() + + handleRNGSeedChange(seed) + } + + return ( +
+ + + ) +} + +RandomSeedInput.propTypes = { + search: string, +} diff --git a/src/components/SettingsView/RandomSeedInput.sass b/src/components/SettingsView/RandomSeedInput.sass new file mode 100644 index 000000000..92f086413 --- /dev/null +++ b/src/components/SettingsView/RandomSeedInput.sass @@ -0,0 +1,3 @@ +.RandomSeedInput + display: flex + justify-content: center diff --git a/src/components/SettingsView/RandomSeedInput.test.js b/src/components/SettingsView/RandomSeedInput.test.js new file mode 100644 index 000000000..1e528a210 --- /dev/null +++ b/src/components/SettingsView/RandomSeedInput.test.js @@ -0,0 +1,35 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import FarmhandContext from '../Farmhand/Farmhand.context' + +import { RandomSeedInput } from './RandomSeedInput' + +const mockHandleRNGSeedChange = jest.fn() + +const MockRandomSeedInput = props => ( + + + +) + +describe('RandomSeedInput', () => { + test('gets initial value from query param', () => { + render() + + expect(screen.getByDisplayValue('123')).toBeInTheDocument() + }) + + test('updates query param', () => { + render() + + const input = screen.getByDisplayValue('123') + + userEvent.type(input, '[Backspace][Backspace][Backspace]456[Enter]') + + expect(mockHandleRNGSeedChange).toHaveBeenCalledWith('456') + }) +}) diff --git a/src/components/SettingsView/SettingsView.js b/src/components/SettingsView/SettingsView.js index 16d04e4ea..34af40d08 100644 --- a/src/components/SettingsView/SettingsView.js +++ b/src/components/SettingsView/SettingsView.js @@ -16,6 +16,7 @@ import FileReaderInput from 'react-file-reader-input' import FarmhandContext from '../Farmhand/Farmhand.context' +import { RandomSeedInput } from './RandomSeedInput' import './SettingsView.sass' const SettingsView = ({ @@ -48,6 +49,8 @@ const SettingsView = ({ + + Options diff --git a/src/game-logic/reducers/applyCrows.js b/src/game-logic/reducers/applyCrows.js index 2edbdff8b..4166d503b 100644 --- a/src/game-logic/reducers/applyCrows.js +++ b/src/game-logic/reducers/applyCrows.js @@ -1,9 +1,9 @@ +import { random } from '../../common/utils' import { doesPlotContainCrop, isRandomNumberLessThan } from '../../utils' import { CROW_CHANCE, MAX_CROWS } from '../../constants' import { CROWS_DESTROYED } from '../../templates' import { modifyFieldPlotAt } from './modifyFieldPlotAt' - import { fieldHasScarecrow } from './helpers' /** @@ -47,12 +47,12 @@ export const applyCrows = state => { const numCrows = Math.min( plotsWithCrops.length, - Math.floor(Math.random() * (purchasedField + 1) * MAX_CROWS) + Math.floor(random() * (purchasedField + 1) * MAX_CROWS) ) let numCropsDestroyed = 0 for (let i = 0; i < numCrows; i++) { - const attackPlotId = Math.floor(Math.random() * plotsWithCrops.length) + const attackPlotId = Math.floor(random() * plotsWithCrops.length) const target = plotsWithCrops.splice(attackPlotId, 1)[0] state = modifyFieldPlotAt(state, target.x, target.y, () => null) diff --git a/src/game-logic/reducers/generatePriceEvents.js b/src/game-logic/reducers/generatePriceEvents.js index bbabeb7d8..bfb5807c0 100644 --- a/src/game-logic/reducers/generatePriceEvents.js +++ b/src/game-logic/reducers/generatePriceEvents.js @@ -8,6 +8,7 @@ import { } from '../../utils' import { PRICE_EVENT_CHANCE } from '../../constants' import { PRICE_CRASH, PRICE_SURGE } from '../../templates' +import { random } from '../../common/utils' import { createPriceEvent } from './createPriceEvent' @@ -26,7 +27,7 @@ export const generatePriceEvents = state => { // TODO: Use isRandomNumberLessThan here once it supports an exclusive // less-than check. - if (Math.random() < PRICE_EVENT_CHANCE) { + if (random() < PRICE_EVENT_CHANCE) { const { items: unlockedItems } = getLevelEntitlements( levelAchieved(farmProductsSold(state.itemsSold)) ) @@ -41,7 +42,7 @@ export const generatePriceEvents = state => { ) if (!doesPriceEventAlreadyExist) { - const priceEventType = Math.random() < 0.5 ? TYPE_CRASH : TYPE_SURGE + const priceEventType = random() < 0.5 ? TYPE_CRASH : TYPE_SURGE priceEvent = createPriceEvent( state, diff --git a/src/game-logic/reducers/minePlot.js b/src/game-logic/reducers/minePlot.js index bfc925842..1b400d9ed 100644 --- a/src/game-logic/reducers/minePlot.js +++ b/src/game-logic/reducers/minePlot.js @@ -2,6 +2,7 @@ import { toolType } from '../../enums' import { chooseRandom, doesInventorySpaceRemain } from '../../utils' import { INVENTORY_FULL_NOTIFICATION } from '../../strings' import { ResourceFactory } from '../../factories' +import { random } from '../../common/utils' import { addItemToInventory } from './addItemToInventory' import { showNotification } from './showNotification' @@ -41,9 +42,7 @@ export const minePlot = (state, x, y) => { // if ore was spawned, add up to 10 days to the time to clear // at random, based loosely on the spawnChance meant to make // rarer ores take longer to cooldown - daysUntilClear += Math.round( - Math.random() * (1 - spawnedOre.spawnChance) * 10 - ) + daysUntilClear += Math.round(random() * (1 - spawnedOre.spawnChance) * 10) for (let resource of spawnedResources) { state = addItemToInventory(state, resource) diff --git a/src/handlers/ui-events.js b/src/handlers/ui-events.js index 84f528907..e67f1aec2 100644 --- a/src/handlers/ui-events.js +++ b/src/handlers/ui-events.js @@ -1,4 +1,5 @@ import { saveAs } from 'file-saver' +import window from 'global/window' import { moneyTotal, @@ -21,6 +22,7 @@ import { plantInPlot, waterPlot, } from '../game-logic/reducers' +import { randomNumberService } from '../common/services/randomNumber' const { CLEANUP, @@ -469,4 +471,31 @@ export default { handleActivePlayerButtonClick() { this.openDialogView(dialogView.ONLINE_PEERS) }, + + /** + * @param {string} seed + */ + handleRNGSeedChange(seed) { + const { origin, pathname, search, hash } = window.location + const queryParams = new URLSearchParams(search) + const trimmedSeed = seed.trim() + + if (trimmedSeed === '') { + randomNumberService.unseedRandomNumber() + queryParams.delete('seed') + + this.showNotification('Random seed reset', 'info') + } else { + randomNumberService.seedRandomNumber(trimmedSeed) + queryParams.set('seed', trimmedSeed) + + this.showNotification(`Random seed set to "${trimmedSeed}"`, 'success') + } + + const newQueryParams = queryParams.toString() + const newSearch = newQueryParams.length > 0 ? `?${newQueryParams}` : '' + + const newUrl = `${origin}${pathname}${newSearch}${hash}` + window.history.replaceState({}, '', newUrl) + }, } diff --git a/src/handlers/ui-events.test.js b/src/handlers/ui-events.test.js index cbf64e1a5..7f24ec1aa 100644 --- a/src/handlers/ui-events.test.js +++ b/src/handlers/ui-events.test.js @@ -1,9 +1,11 @@ import React from 'react' import { shallow } from 'enzyme' +import window from 'global/window' import Farmhand from '../components/Farmhand' import { stageFocusType, fieldMode } from '../enums' import { testItem } from '../test-utils' +import { randomNumberService } from '../common/services/randomNumber' jest.mock('../data/items') jest.mock('../data/levels', () => ({ @@ -170,3 +172,33 @@ describe('handleShowHomeScreenChange', () => { expect(component.state().stageFocus).toEqual(stageFocusType.SHOP) }) }) + +describe('handleRNGSeedChange', () => { + test('updates random seed', () => { + jest.spyOn(window.history, 'replaceState') + jest.spyOn(randomNumberService, 'seedRandomNumber') + + handlers().handleRNGSeedChange('123') + + expect(window.history.replaceState).toHaveBeenCalledWith( + {}, + '', + expect.stringContaining('?seed=123') + ) + expect(randomNumberService.seedRandomNumber).toHaveBeenCalledWith('123') + }) + + test('resets random seed', () => { + jest.spyOn(window.history, 'replaceState') + jest.spyOn(randomNumberService, 'unseedRandomNumber') + + handlers().handleRNGSeedChange('') + + expect(window.history.replaceState).toHaveBeenCalledWith( + {}, + '', + expect.not.stringContaining('?') + ) + expect(randomNumberService.unseedRandomNumber).toHaveBeenCalled() + }) +}) diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts new file mode 100644 index 000000000..6ab1b73d2 --- /dev/null +++ b/src/react-app-env.d.ts @@ -0,0 +1,8 @@ +/// + +// TODO: Contribute type definitions for 'global' package to +// https://github.com/DefinitelyTyped/DefinitelyTyped +// @see https://github.com/jeremyckahn/farmhand/issues/399 +declare module 'global/window' { + export default window as Window +} diff --git a/src/utils.js b/src/utils.js index ec95cf87b..e18669464 100644 --- a/src/utils.js +++ b/src/utils.js @@ -76,6 +76,7 @@ import { STORM_CHANCE, STORAGE_EXPANSION_SCALE_PREMIUM, } from './constants' +import { random } from './common/utils' const Jimp = configureJimp({ types: [jimpPng], @@ -96,7 +97,7 @@ const purchasableItemMap = [...cowShopInventory, ...shopInventory].reduce( * @return {number} */ export const chooseRandomIndex = list => - Math.round(Math.random() * (list.length - 1)) + Math.round(random() * (list.length - 1)) /** * @param {Array.<*>} list @@ -499,7 +500,7 @@ export const generateCow = (options = {}) => { COW_STARTING_WEIGHT_BASE * (gender === genders.MALE ? MALE_COW_WEIGHT_MULTIPLIER : 1) - COW_STARTING_WEIGHT_VARIANCE + - Math.random() * (COW_STARTING_WEIGHT_VARIANCE * 2) + random() * (COW_STARTING_WEIGHT_VARIANCE * 2) ) const cow = { @@ -1191,7 +1192,7 @@ export function randomChoice(weightedOptions) { sortedOptions.sort(o => o.weight) - let diceRoll = Math.random() * totalWeight + let diceRoll = random() * totalWeight let option let runningTotal = 0 @@ -1309,8 +1310,8 @@ export const isInViewport = element => { ) } -export const shouldPrecipitateToday = () => Math.random() < PRECIPITATION_CHANCE -export const shouldStormToday = () => Math.random() < STORM_CHANCE +export const shouldPrecipitateToday = () => random() < PRECIPITATION_CHANCE +export const shouldStormToday = () => random() < STORM_CHANCE /** * @param {farmhand.cow} cow diff --git a/src/utils/isRandomNumberLessThan.js b/src/utils/isRandomNumberLessThan.js index c5a9d62d9..3e2d95ff9 100644 --- a/src/utils/isRandomNumberLessThan.js +++ b/src/utils/isRandomNumberLessThan.js @@ -1,8 +1,12 @@ +import { random } from '../common/utils' + +// TODO: Migrate this to src/common/services/randomNumber.js +// @see https://github.com/jeremyckahn/farmhand/issues/400 /** * Compares given number against a randomly generated number * @param {number} chance float between 0-1 to compare dice roll against * @returns {bool} true if the dice roll was equal to or lower than the given chance, false otherwise */ export default function isRandomNumberLessThan(chance) { - return Math.random() <= chance + return random() <= chance }