UNPKG

@jeremyckahn/farmhand

Version:
1,262 lines (1,091 loc) 32.9 kB
/** * @module farmhand.utils * @ignore */ import { Buffer } from 'buffer' import configureJimp from '@jimp/custom' import jimpPng from '@jimp/png' import Dinero from 'dinero.js' import { funAnimalName } from 'fun-animal-names' import sortBy from 'lodash.sortby' import { v4 as uuid } from 'uuid' import { random } from '../common/utils.js' import { BREAKPOINTS, COW_COLORS_HEX_MAP, COW_FERTILIZER_PRODUCTION_RATE_FASTEST, COW_FERTILIZER_PRODUCTION_RATE_SLOWEST, COW_MAXIMUM_VALUE_MATURITY_AGE, COW_MAXIMUM_VALUE_MULTIPLIER, COW_MILK_RATE_FASTEST, COW_MILK_RATE_SLOWEST, COW_MINIMUM_VALUE_MULTIPLIER, COW_STARTING_WEIGHT_BASE, COW_STARTING_WEIGHT_VARIANCE, COW_WEIGHT_MULTIPLIER_MAXIMUM, COW_WEIGHT_MULTIPLIER_MINIMUM, DAILY_FINANCIAL_HISTORY_RECORD_LENGTH, HUGGING_MACHINE_ITEM_ID, I_AM_RICH_BONUSES, INFINITE_STORAGE_LIMIT, INITIAL_FIELD_HEIGHT, INITIAL_FIELD_WIDTH, INITIAL_FOREST_HEIGHT, INITIAL_FOREST_WIDTH, INITIAL_STORAGE_LIMIT, LARGEST_PURCHASABLE_FIELD_SIZE, MALE_COW_WEIGHT_MULTIPLIER, PEER_METADATA_STATE_KEYS, PERSISTED_STATE_KEYS, PRECIPITATION_CHANCE, PRICE_EVENT_STANDARD_DURATION_DECREASE, STANDARD_VIEW_LIST, STORAGE_EXPANSION_AMOUNT, STORAGE_EXPANSION_BASE_PRICE, STORAGE_EXPANSION_SCALE_PREMIUM, STORM_CHANCE, } from '../constants.js' import fruitNames from '../data/fruit-names.js' import { chocolateMilk, milk1, milk2, milk3, rainbowMilk1, rainbowMilk2, rainbowMilk3, } from '../data/items.js' import { unlockableItems } from '../data/levels.js' import { cropItemIdToSeedItemMap, itemsMap } from '../data/maps.js' import cowShopInventory from '../data/shop-inventory-cow.js' import shopInventory from '../data/shop-inventory.js' import { cowColors, cropLifeStage, fertilizerType, genders, itemType, stageFocusType, standardCowColors, } from '../enums.js' import { animals, items as itemImages, pixel } from '../img/index.js' import { farmProductsSold } from './farmProductsSold.js' import { getCropLifecycleDuration } from './getCropLifecycleDuration.js' import { getInventoryQuantityMap } from './getInventoryQuantityMap.js' import { getItemBaseValue } from './getItemBaseValue.js' import { getLevelEntitlements } from './getLevelEntitlements.js' import { memoize } from './memoize.js' const Jimp = configureJimp({ types: [jimpPng], }) const { SEED, GROWING, GROWN } = cropLifeStage /** * @param {unknown} obj * @returns {obj is farmhand.plotContent} */ const isPlotContent = (obj = {}) => Boolean(obj && obj['itemId'] && obj['fertilizerType']) /** * @param {unknown} obj * @returns {obj is farmhand.shoveledPlot} */ const isShoveledPlot = (obj = {}) => Boolean(obj && obj['isShoveled'] && obj['daysUntilClear']) const purchasableItemMap = [...cowShopInventory, ...shopInventory].reduce( (acc, item) => { acc[item.id] = item return acc }, {} ) /** * @param {Array.<*>} list * @return {number} */ export const chooseRandomIndex = list => Math.round(random() * (list.length - 1)) /** * @param {Array.<*>} list * @return {*} */ export const chooseRandom = list => list[chooseRandomIndex(list)] /** * Ensures that the condition argument to memoize() is not ignored, per * https://github.com/caiogondim/fast-memoize.js#function-arguments * * Pass this is the `serializer` option to any memoize()-ed functions that * accept function arguments. * @param {any[]} args */ export const memoizationSerializer = args => JSON.stringify( [...args].map(arg => (typeof arg === 'function' ? arg.toString() : arg)) ) /** * @param {number} num * @param {number} min * @param {number} max */ export const clampNumber = (num, min, max) => num <= min ? min : num >= max ? max : num /** * @param {number} num */ export const castToMoney = num => Math.round(num * 100) / 100 /** * Safely adds dollar figures to avoid IEEE 754 rounding errors. * @param {...number} args Numbers that represent money values. * @returns {number} * @see http://adripofjavascript.com/blog/drips/avoiding-problems-with-decimal-math-in-javascript.html */ export const moneyTotal = (...args) => args.reduce((sum, num) => (sum += Math.round(num * 100)), 0) / 100 /** * Based on https://stackoverflow.com/a/14224813/470685 * @param {number} value Number to scale * @param {number} min Non-standard minimum * @param {number} max Non-standard maximum * @param {number} baseMin Standard minimum * @param {number} baseMax Standard maximum * @returns {number} */ export const scaleNumber = (value, min, max, baseMin, baseMax) => ((value - min) * (baseMax - baseMin)) / (max - min) + baseMin /** * @param {string} string * @returns {number} */ const convertStringToInteger = string => string.split('').reduce((acc, char, i) => acc + char.charCodeAt(0) * i, 0) export const createNewField = () => new Array(INITIAL_FIELD_HEIGHT) .fill(undefined) .map(() => new Array(INITIAL_FIELD_WIDTH).fill(null)) export const createNewForest = () => { return new Array(INITIAL_FOREST_HEIGHT) .fill(undefined) .map(() => new Array(INITIAL_FOREST_WIDTH).fill(null)) } /** * @param {number} number * @param {string} format * @see https://dinerojs.com/module-dinero#~toFormat * @returns {string} */ const formatNumber = (number, format) => Dinero({ amount: Math.round(number * 100), precision: 2 }) .convertPrecision(0) .toFormat(format) /** * @param {number} number * @returns {string} Include dollar sign and other formatting. Cents are * rounded off. */ export const dollarString = number => formatNumber(number, '$0,0') /** * @param {number} number * @returns {string} Number string with commas. */ export const integerString = number => formatNumber(number, '0,0') /** * @param {number} number A float * @returns {string} the float converted to a full number with a % added */ export const percentageString = number => `${Math.round(number * 100)}%` /** * @param {farmhand.item} item * @param {Record<string, number>} valueAdjustments * @returns {number} */ export const getItemCurrentValue = ({ id }, valueAdjustments) => Dinero({ amount: Math.round( (valueAdjustments[id] ? getItemBaseValue(id) * (itemsMap[id].doesPriceFluctuate ? valueAdjustments[id] : 1) : getItemBaseValue(id)) * 100 ), precision: 2, }).toUnit() /** * @param {Record<string, number>} valueAdjustments * @param {string} itemId * @returns {number} Rounded to a money value. */ export const getAdjustedItemValue = (valueAdjustments, itemId) => Number(((valueAdjustments[itemId] || 1) * itemsMap[itemId].value).toFixed(2)) /** * @param {farmhand.item} item * @returns {boolean} */ export const isItemSoldInShop = ({ id }) => Boolean(purchasableItemMap[id]) /** * @param {farmhand.item} item * @returns {number} */ export const getResaleValue = ({ id }) => itemsMap[id].value / 2 /** * @param {string} itemId * @returns {farmhand.plotContent} */ export const getPlotContentFromItemId = itemId => ({ itemId, fertilizerType: fertilizerType.NONE, }) /** * @param {string} itemId * @returns {farmhand.crop} */ export const getCropFromItemId = itemId => ({ ...getPlotContentFromItemId(itemId), daysOld: 0, daysWatered: 0, wasWateredToday: false, }) /** * @param {farmhand.plotContent} plotContent * @returns {?string} */ export const getPlotContentType = ({ itemId }) => itemId ? itemsMap[itemId].type : null /** * @param {?farmhand.plotContent} plot * @returns {plot is farmhand.crop} */ export const doesPlotContainCrop = plot => plot !== null && getPlotContentType(plot) === itemType.CROP export const getLifeStageRange = memoize(( /** @type {number[]} */ cropTimeline ) => { let lifeStageRange = Array(cropTimeline[0]).fill(SEED) lifeStageRange = lifeStageRange.concat( cropTimeline .slice(1) .reduce( (/** @type {number[]} */ acc, value) => acc.concat(Array(value).fill(GROWING)), [] ) ) return lifeStageRange }) /** * @param {farmhand.crop} crop * @returns {number} */ export const getGrowingPhase = memoize( crop => { const { itemId, daysWatered } = crop const { cropTimeline = [] } = itemsMap[itemId] let daysGrowing = daysWatered + 1 let phase = 0 for (let value of cropTimeline) { if (daysGrowing - value <= 0) break daysGrowing -= value phase += 1 } return phase }, { cacheSize: LARGEST_PURCHASABLE_FIELD_SIZE.columns * LARGEST_PURCHASABLE_FIELD_SIZE.rows, } ) /** * @param {farmhand.crop} crop * @returns {farmhand.cropLifeStage} */ export const getCropLifeStage = crop => { const { itemId, daysWatered } = crop const { cropTimeline } = itemsMap[itemId] if (!cropTimeline) { throw new Error(`${itemId} has no cropTimeline`) } return getLifeStageRange(cropTimeline)[Math.floor(daysWatered || 0)] || GROWN } /** * @param {farmhand.plotContent | farmhand.shoveledPlot | null} plotContents * @param {number} x * @param {number} y * @returns {?string} */ export const getPlotImage = (plotContents, x, y) => { if (isPlotContent(plotContents)) { if (isPlotContentACrop(plotContents)) { let itemImageId switch (getCropLifeStage(plotContents)) { case GROWN: itemImageId = plotContents.itemId break case GROWING: const phase = getGrowingPhase(plotContents) itemImageId = `${plotContents.itemId}-growing-${phase}` break default: const seedItem = cropItemIdToSeedItemMap[plotContents.itemId] itemImageId = seedItem.id } return itemImages[itemImageId] } if (getPlotContentType(plotContents) === itemType.WEED) { const weedColors = ['yellow', 'orange', 'pink'] const color = weedColors[(x * y) % weedColors.length] return itemImages[`weed-${color}`] } // Handle other plot content (non-crop, non-weed) return itemImages[/** @type {farmhand.plotContent} */ (plotContents).itemId] } if (isShoveledPlot(plotContents)) { if (plotContents?.oreId) { return itemImages[plotContents.oreId] } } return null } /** * @param {number} rangeSize * @param {number} centerX * @param {number} centerY * @returns {{x: number, y: number}[][]} */ export const getRangeCoords = (rangeSize, centerX, centerY) => { const squareSize = 2 * rangeSize + 1 const rangeStartX = centerX - rangeSize const rangeStartY = centerY - rangeSize return new Array(squareSize) .fill(null) .map((_, y) => new Array(squareSize) .fill(null) .map((_, x) => ({ x: rangeStartX + x, y: rangeStartY + y })) ) } /** * @param {farmhand.item} item * @param {number} [variantIdx] * @returns {farmhand.item | undefined} */ export const getFinalCropItemFromSeedItem = ({ id }, variantIdx = 0) => { const itemId = getFinalCropItemIdFromSeedItemId(id, variantIdx) if (itemId) return itemsMap[itemId] } /** * @param {string} seedItemId * @param {number} [variationIdx] * @returns {string=} */ export const getFinalCropItemIdFromSeedItemId = ( seedItemId, variationIdx = 0 ) => { const { growsInto } = itemsMap[seedItemId] if (Array.isArray(growsInto)) { return growsInto[variationIdx] } else { return growsInto } } export const getSeedItemIdFromFinalStageCropItemId = memoize( /** @type {string} */ cropItemId => { const seedItemId = Object.values(itemsMap).find(({ growsInto }) => { if (Array.isArray(growsInto)) { return growsInto.includes(cropItemId) } else { return growsInto === cropItemId } })?.id if (!seedItemId) throw new Error( `Crop item ID ${cropItemId} does not have a corresponding seed` ) return seedItemId }, { cacheSize: Object.keys(itemsMap).length, } ) /** * @param {farmhand.cow} cow * @returns {string} */ const getDefaultCowName = ({ id }) => fruitNames[convertStringToInteger(id) % fruitNames.length] /** * @param {farmhand.cow} cow * @param {string} playerId * @param {boolean} allowCustomPeerCowNames * @returns {string} */ export const getCowDisplayName = (cow, playerId, allowCustomPeerCowNames) => { return cow.originalOwnerId !== playerId && !allowCustomPeerCowNames ? getDefaultCowName(cow) : cow.name } /** * Generates a friendly cow. * @param {Object} [options] * @returns {farmhand.cow} */ export const generateCow = (options = {}) => { const gender = options.gender || chooseRandom(Object.values(genders)) const color = options.color || chooseRandom(Object.values(standardCowColors)) const id = options.id || uuid() const baseWeight = Math.round( COW_STARTING_WEIGHT_BASE * (gender === genders.MALE ? MALE_COW_WEIGHT_MULTIPLIER : 1) - COW_STARTING_WEIGHT_VARIANCE + random() * (COW_STARTING_WEIGHT_VARIANCE * 2) ) const cow = { baseWeight, color, colorsInBloodline: { [color]: true }, daysOld: 1, daysSinceMilking: 0, daysSinceProducingFertilizer: 0, gender, happiness: 0, happinessBoostsToday: 0, id, isBred: false, isUsingHuggingMachine: false, name: '', ownerId: '', originalOwnerId: '', timesTraded: 0, weightMultiplier: 1, ...options, } cow.name = getDefaultCowName(cow) return cow } /** * Generates a cow based on two parents. * @param {farmhand.cow} cow1 * @param {farmhand.cow} cow2 * @param {string} ownerId * @param {Partial<farmhand.cow>?} customProps * @returns {farmhand.cow} */ export const generateOffspringCow = (cow1, cow2, ownerId, customProps = {}) => { if (cow1.gender === cow2.gender) { throw new Error( `${JSON.stringify(cow1)} ${JSON.stringify( cow2 )} cannot produce offspring because they have the same gender` ) } const maleCow = cow1.gender === genders.MALE ? cow1 : cow2 const femaleCow = cow1.gender === genders.MALE ? cow2 : cow1 const colorsInBloodline = { // These lines are for backwards compatibility and can be removed on 11/1/2020 [maleCow.color]: true, [femaleCow.color]: true, // End backwards compatibility lines to remove ...maleCow.colorsInBloodline, ...femaleCow.colorsInBloodline, } delete colorsInBloodline[ /** @type {keyof typeof colorsInBloodline} */ (cowColors.RAINBOW) ] const isRainbowCow = Object.keys(colorsInBloodline).length === Object.keys(standardCowColors).length return generateCow({ color: isRainbowCow ? cowColors.RAINBOW : chooseRandom([femaleCow.color, maleCow.color]), colorsInBloodline, baseWeight: (maleCow.baseWeight + femaleCow.baseWeight) / 2, isBred: true, ownerId, originalOwnerId: ownerId, ...customProps, }) } /** * @param {farmhand.cow} cow * @returns {farmhand.item} */ export const getCowMilkItem = ({ color, happiness }) => { if (color === cowColors.BROWN) { return chocolateMilk } const isRainbowCow = color === cowColors.RAINBOW if (happiness < 1 / 3) { return isRainbowCow ? rainbowMilk1 : milk1 } else if (happiness < 2 / 3) { return isRainbowCow ? rainbowMilk2 : milk2 } return isRainbowCow ? rainbowMilk3 : milk3 } /** * @param {farmhand.cow} cow * @returns {farmhand.item} */ export const getCowFertilizerItem = ({ color }) => itemsMap[color === cowColors.RAINBOW ? 'rainbow-fertilizer' : 'fertilizer'] /** * @param {farmhand.cow} cow * @returns {number} */ export const getCowMilkRate = cow => cow.gender === genders.FEMALE ? scaleNumber( cow.weightMultiplier, COW_WEIGHT_MULTIPLIER_MINIMUM, COW_WEIGHT_MULTIPLIER_MAXIMUM, COW_MILK_RATE_SLOWEST, COW_MILK_RATE_FASTEST ) : Infinity /** * @param {farmhand.cow} cow * @returns {number} */ export const getCowFertilizerProductionRate = cow => cow.gender === genders.MALE ? scaleNumber( cow.weightMultiplier, COW_WEIGHT_MULTIPLIER_MINIMUM, COW_WEIGHT_MULTIPLIER_MAXIMUM, COW_FERTILIZER_PRODUCTION_RATE_SLOWEST, COW_FERTILIZER_PRODUCTION_RATE_FASTEST ) : Infinity /** * @param {farmhand.cow} cow * @returns {number} */ export const getCowWeight = ({ baseWeight, weightMultiplier }) => Math.round(baseWeight * weightMultiplier) /** * @param {farmhand.cow} cow * @param {boolean} [computeSaleValue=false] * @returns {number} */ export const getCowValue = (cow, computeSaleValue = false) => computeSaleValue ? getCowWeight(cow) * clampNumber( scaleNumber( cow.daysOld, 1, COW_MAXIMUM_VALUE_MATURITY_AGE, COW_MINIMUM_VALUE_MULTIPLIER, COW_MAXIMUM_VALUE_MULTIPLIER ), COW_MINIMUM_VALUE_MULTIPLIER, COW_MAXIMUM_VALUE_MULTIPLIER ) : getCowWeight(cow) * 1.5 /** * @param {farmhand.cow} cow */ export const getCowSellValue = cow => getCowValue(cow, true) /** * @param {farmhand.recipe} recipe * @param {{id: string, quantity: number}[]} inventory * @returns {number} */ export const maxYieldOfRecipe = memoize(({ ingredients }, inventory) => { const inventoryQuantityMap = getInventoryQuantityMap(inventory) return ( Math.min( ...Object.keys(ingredients).map(itemId => Math.floor(inventoryQuantityMap[itemId] / ingredients[itemId]) ) ) || 0 ) }) /** * @param {farmhand.recipe} recipe * @param {{id: string, quantity: number}[]} inventory * @param {number} howMany * @returns {boolean} */ export const canMakeRecipe = (recipe, inventory, howMany) => maxYieldOfRecipe(recipe, inventory) >= howMany /** * @param {string[]} itemsIds * @returns {string[]} */ export const filterItemIdsToSeeds = itemsIds => itemsIds.filter(id => itemsMap[id]?.type === itemType.CROP) /** * @param {Array.<string>} unlockedSeedItemIds * @returns {farmhand.item} */ export const getRandomUnlockedCrop = unlockedSeedItemIds => { const seedItemId = chooseRandom(unlockedSeedItemIds) const seedItem = itemsMap[seedItemId] const variationIdx = Array.isArray(seedItem.growsInto) ? chooseRandomIndex(seedItem.growsInto) : 0 const finalCropItemId = getFinalCropItemIdFromSeedItemId( seedItemId, variationIdx ) if (!finalCropItemId) throw new Error( `Seed item ID ${seedItemId} has no corresponding final crop ID` ) return itemsMap[finalCropItemId] } /** * @param {farmhand.item} cropItem * @returns {farmhand.priceEvent} */ export const getPriceEventForCrop = cropItem => ({ itemId: cropItem.id, daysRemaining: getCropLifecycleDuration(cropItem) - PRICE_EVENT_STANDARD_DURATION_DECREASE, }) export const doesMenuObstructStage = () => window.innerWidth < BREAKPOINTS.MD /** @type {Set<farmhand.itemType>} */ const itemTypesToShowInReverse = new Set([itemType.MILK]) const sortItemIdsByTypeAndValue = memoize(itemIds => sortBy(itemIds, [ id => Number(itemsMap[id].type !== itemType.CROP), id => { const { type, value } = itemsMap[id] return itemTypesToShowInReverse.has(type) ? -value : value }, ]) ) /** * @param {Array.<farmhand.item>} items * @return {Array.<farmhand.item>} */ export const sortItems = items => { const map = {} items.forEach(item => (map[item.id] = item)) return sortItemIdsByTypeAndValue(items.map(({ id }) => id)).map(id => map[id]) } export const inventorySpaceConsumed = memoize( /** * @param {farmhand.state['inventory']} inventory * @returns {number} */ inventory => inventory.reduce((sum, { quantity = 0 }) => sum + quantity, 0) ) /** * @param {{ inventory: farmhand.state['inventory'], inventoryLimit: farmhand.state['inventoryLimit'] }} state * @returns {number} */ export const inventorySpaceRemaining = ({ inventory, inventoryLimit }) => inventoryLimit === INFINITE_STORAGE_LIMIT ? Infinity : Math.max(0, inventoryLimit - inventorySpaceConsumed(inventory)) /** * @param {farmhand.state} state * @returns {boolean} */ export const doesInventorySpaceRemain = state => inventorySpaceRemaining(state) > 0 export const areHuggingMachinesInInventory = memoize( /** * @param {farmhand.state['inventory']} inventory * @return {boolean} */ inventory => inventory.some(({ id }) => id === HUGGING_MACHINE_ITEM_ID) ) /** * @param {number} arraySize * @returns {Array.<null>} */ export const nullArray = memoize( arraySize => Object.freeze(new Array(arraySize).fill(null)), { cacheSize: 30, } ) export const findCowById = memoize( /** * @param {Array.<farmhand.cow>} cowInventory * @param {string} id * @returns {farmhand.cow|undefined} */ (cowInventory, id) => cowInventory.find(cow => id === cow.id) ) /** * @param {number} targetLevel * @returns {number} */ export const experienceNeededForLevel = targetLevel => ((targetLevel - 1) * 10) ** 2 export const getAvailableShopInventory = memoize(( /** @type {farmhand.levelEntitlements} */ levelEntitlements ) => shopInventory.filter( ({ id }) => !( unlockableItems.hasOwnProperty(id) && !levelEntitlements.items.hasOwnProperty(id) ) ) ) /** * @param {number} level * @returns {farmhand.item} Will always be a crop seed item. */ export const getRandomLevelUpReward = level => itemsMap[ chooseRandom( filterItemIdsToSeeds(Object.keys(getLevelEntitlements(level).items)) ) ] /** * @param {number} level * @returns {number} */ export const getRandomLevelUpRewardQuantity = level => level * 10 /** * @param {farmhand.state} state * @returns {Object} Data that is meant to be shared with Trystero peers. */ /** * @param {farmhand.state} state * @returns {farmhand.peerMetadata} */ export const getPeerMetadata = state => { const reducedState = PEER_METADATA_STATE_KEYS.reduce( (acc, key) => { acc[key] = state[key] return acc }, /** @type {Partial<farmhand.peerMetadata>} */ ({}) ) Object.assign(reducedState, { cowOfferedForTrade: state.cowInventory.find( ({ id }) => id === state.cowIdOfferedForTrade ), }) return /** @type {farmhand.peerMetadata} */ (reducedState) } /** * @param {Partial<farmhand.state>} state * @returns {farmhand.state} A version of `state` that only contains keys of * farmhand.state data that should be persisted. */ export const reduceByPersistedKeys = state => /** @type {farmhand.state} */ (PERSISTED_STATE_KEYS.reduce(( /** @type {Partial<farmhand.state>} */ acc, key ) => { // This check prevents old exports from corrupting game state when // imported. if (typeof state[key] !== 'undefined') { acc[key] = state[key] } return acc }, {})) /** * @param {Array.<number>} historicalData Must be no longer than 7 numbers long. * @return {number} */ export const get7DayAverage = historicalData => historicalData.reduce((sum, revenue) => moneyTotal(sum, revenue), 0) / DAILY_FINANCIAL_HISTORY_RECORD_LENGTH const cowColorToIdMap = { [cowColors.BLUE]: 'blue', [cowColors.BROWN]: 'brown', [cowColors.GREEN]: 'green', [cowColors.ORANGE]: 'orange', [cowColors.PURPLE]: 'purple', [cowColors.RAINBOW]: 'rainbow', [cowColors.WHITE]: 'white', [cowColors.YELLOW]: 'yellow', } export const getCowColorId = ({ color }) => `${cowColorToIdMap[color]}-cow` /** * @param {number} revenue * @param {number} losses * @return {number} */ export const getProfit = (revenue, losses) => moneyTotal(revenue, losses) /** * @param {number} recordSingleDayProfit * @param {number} todaysRevenue * @param {number} todaysLosses * @returns {number} */ export const getProfitRecord = ( recordSingleDayProfit, todaysRevenue, todaysLosses ) => Math.max(recordSingleDayProfit, getProfit(todaysRevenue, todaysLosses)) /** * @param {farmhand.state['todaysStartingInventory']} todaysStartingInventory * @param {farmhand.state['todaysPurchases']} todaysPurchases * @param {{ id: farmhand.item['id'], quantity: number }[]} inventory * @return {Object} Keys are item IDs, values are either 1 or -1. */ export const computeMarketPositions = ( todaysStartingInventory, todaysPurchases, inventory ) => inventory.reduce((acc, { id, quantity: endingPosition }) => { const startingInventory = todaysStartingInventory[id] || 0 const purchaseQuantity = todaysPurchases[id] || 0 if (!itemsMap[id].doesPriceFluctuate) { return acc } if (startingInventory !== endingPosition) { if ( endingPosition < startingInventory || endingPosition < purchaseQuantity ) { acc[id] = -1 } else if ( endingPosition > startingInventory || endingPosition > purchaseQuantity ) { acc[id] = 1 } } return acc }, {}) /** * @param {farmhand.state} state * @return {farmhand.state} */ export const transformStateDataForImport = /** @type {(state: any) => farmhand.state} */ state => { let sanitizedState = { ...state } const rejectedKeys = ['version'] rejectedKeys.forEach(rejectedKey => delete sanitizedState[rejectedKey]) if (sanitizedState.experience === 0) { sanitizedState.experience = farmProductsSold(sanitizedState.itemsSold || {}) } if ( sanitizedState.showHomeScreen === false && sanitizedState.stageFocus === stageFocusType.HOME ) { sanitizedState = { ...sanitizedState, stageFocus: /** @type {farmhand.stageFocusType} */ (STANDARD_VIEW_LIST[0]), } } // NOTE: This is a mitigation for // https://github.com/jeremyckahn/farmhand/issues/546. There's no expected // scenario where a cow would be present in cowBreedingPen but not // cowInventory, but at least one player's game somehow got into that state. // This block detects such an invalid state and corrects it. { const { cowId1, cowId2 } = sanitizedState.cowBreedingPen const cowPenIdMap = sanitizedState.cowInventory.reduce( /** * @param acc {Record<string, farmhand.cow>} * @param cow {farmhand.cow} */ (acc, cow) => { acc[cow.id] = cow return acc }, {} ) const isCowInBreedingPenMissingFromInventory = [cowId1, cowId2].some( cowId => { return cowId && !(cowId in cowPenIdMap) } ) if (isCowInBreedingPenMissingFromInventory) { // Resets cowBreedingPen state sanitizedState.cowBreedingPen = { cowId1: null, cowId2: null, daysUntilBirth: -1, } } } // NOTE: Legacy data trasformation for https://github.com/jeremyckahn/farmhand/issues/387 // @ts-expect-error if (sanitizedState.id) { // @ts-expect-error sanitizedState.playerId = sanitizedState.id // @ts-expect-error delete sanitizedState.id } return sanitizedState } export const getPlayerName = memoize( /** * @param {string} playerId * @returns {string} */ playerId => { return funAnimalName(playerId) } ) /** * @param {number} currentInventoryLimit * @returns {number} */ export const getCostOfNextStorageExpansion = currentInventoryLimit => { const upgradesPurchased = (currentInventoryLimit - INITIAL_STORAGE_LIMIT) / STORAGE_EXPANSION_AMOUNT return ( STORAGE_EXPANSION_BASE_PRICE + upgradesPurchased * STORAGE_EXPANSION_SCALE_PREMIUM ) } /** * Create a no-op Promise that resolves in a specified amount of time. * @param {number} ms * @returns {Promise<void>} */ export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) /** * @param {object} completedAchievements from game state * @returns {number} multiplier to be used for sales price adjustments based on completedAchievements */ export const getSalePriceMultiplier = (completedAchievements = {}) => { let salePriceMultiplier = 1 if (completedAchievements['i-am-rich-3']) { salePriceMultiplier += I_AM_RICH_BONUSES[2] } else if (completedAchievements['i-am-rich-2']) { salePriceMultiplier += I_AM_RICH_BONUSES[1] } else if (completedAchievements['i-am-rich-1']) { salePriceMultiplier += I_AM_RICH_BONUSES[0] } return salePriceMultiplier } /** * @param {farmhand.plotContent} plotContents * @returns {plotContents is farmhand.crop} */ const isPlotContentACrop = plotContents => getPlotContentType(plotContents) === itemType.CROP /** * @template T * @param {Array.<T & { weight: number }>} weightedOptions an array of objects each containing a `weight` property * @returns {T} one of the items from weightedOptions */ export function randomChoice(weightedOptions) { let totalWeight = 0 let sortedOptions = [] for (let option of weightedOptions) { totalWeight += option.weight sortedOptions.push(option) } sortedOptions.sort(o => o.weight) let diceRoll = random() * totalWeight let option let runningTotal = 0 for (let i in sortedOptions) { option = sortedOptions[i] if (diceRoll < option.weight + runningTotal) { return option } runningTotal += option.weight } // Fallback to the last option if no match found return sortedOptions[sortedOptions.length - 1] } const colorizeCowTemplate = (() => { const cowImageWidth = 48 const cowImageHeight = 48 const cowImageFactoryCanvas = document.createElement('canvas') cowImageFactoryCanvas.setAttribute('height', String(cowImageHeight)) cowImageFactoryCanvas.setAttribute('width', String(cowImageWidth)) const cachedCowImages = {} // https://stackoverflow.com/a/5624139 const hexToRgb = memoize(hex => { const [, r, g, b] = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec( hex ) ?? ['', '0', '0', '0'] return { r: parseInt(r, 16), g: parseInt(g, 16), b: parseInt(b, 16), } }) /** * @param {string} cowTemplate Base64 representation of an image * @param {string} color * @returns {Promise.<string>} Base64 representation of an image */ return async (cowTemplate, color) => { if (color === cowColors.RAINBOW) return animals.cow.rainbow const imageKey = `${color}_${cowTemplate}` if (cachedCowImages[imageKey]) return cachedCowImages[imageKey] try { // `data:image/png;base64,` needs to be removed from the base64 string // before being provided to Buffer. // https://github.com/oliver-moran/jimp/issues/231#issuecomment-282167737 const cowTemplateBuffer = Buffer.from( cowTemplate.split(',')[1] ?? '', 'base64' ) const image = await Jimp.read(cowTemplateBuffer) image.scan(0, 0, image.bitmap.width, image.bitmap.height, function(x, y) { const { r, g, b } = Jimp.intToRGBA(image.getPixelColor(x, y)) // rgb(102, 102, 102) represents the color to replace in the template // source images (#666). if (r === 102 && g === 102 && b === 102) { const cowColorRgb = hexToRgb(COW_COLORS_HEX_MAP[color]) const colorNumber = Jimp.rgbaToInt( cowColorRgb.r, cowColorRgb.g, cowColorRgb.b, 255 ) image.setPixelColor(colorNumber, x, y) } }) cachedCowImages[imageKey] = await image.getBase64Async(Jimp.MIME_PNG) return cachedCowImages[imageKey] } catch (e) { // Jimp.read() expectedly errors out when it receives an empty buffer, // which it will in some unit tests. if (import.meta.env?.MODE !== 'test') { console.error(e) } return pixel } } })() /** * @param {farmhand.cow} cow * @returns {Promise<string>} Base64 representation of an image */ export const getCowImage = async cow => { const cowIdNumber = convertStringToInteger(cow.id) const { variations } = animals.cow const cowTemplate = variations[cowIdNumber % variations.length] return await colorizeCowTemplate(cowTemplate, cow.color) } /** * Adapted from https://www.javascripttutorial.net/dom/css/check-if-an-element-is-visible-in-the-viewport/ * @param {Element} element * @returns {boolean} */ export const isInViewport = element => { const { top, left, bottom, right } = element.getBoundingClientRect() return ( top >= 0 && left >= 0 && bottom <= (window.innerHeight || document.documentElement.clientHeight) && right <= (window.innerWidth || document.documentElement.clientWidth) ) } export const shouldPrecipitateToday = () => random() < PRECIPITATION_CHANCE export const shouldStormToday = () => random() < STORM_CHANCE /** * @param {farmhand.cow} cow * @param {farmhand.cowBreedingPen} cowBreedingPen * @returns {boolean} */ export const isCowInBreedingPen = (cow, cowBreedingPen) => cowBreedingPen.cowId1 === cow.id || cowBreedingPen.cowId2 === cow.id /** * @returns {boolean} */ export const isOctober = () => new Date().getMonth() === 9 /** * @returns {boolean} */ export const isDecember = () => new Date().getMonth() === 11