From ad021cf9a551a476da05b63ec12f2965f0eff1e8 Mon Sep 17 00:00:00 2001 From: Sage Vaillancourt Date: Thu, 19 May 2022 11:09:16 -0400 Subject: [PATCH] Many additions: Add several new achievements Add price querying with ?b Simplify commands' argument-handling Some spooky stuff New quackgrades Stormy weather Rebalanced some rare-event odds !u buttons Reworked prestige emojis Save on every command Add stonks More temp poll triggers Redemption upgrades more powerful Fuzzy matching for usernames and buyables --- src/auth/index.js | 2 +- src/games/hvacoins/achievements.js | 82 +++ src/games/hvacoins/buy.js | 58 +- src/games/hvacoins/buyableItems.js | 42 +- src/games/hvacoins/index.js | 904 ++++++++++++++++++++++------- src/games/hvacoins/lore.js | 22 +- src/games/hvacoins/prestige.js | 39 +- src/games/hvacoins/quackstore.js | 62 +- src/games/hvacoins/settings.js | 3 +- src/games/hvacoins/upgrades.js | 82 ++- src/games/hvacoins/utils.js | 90 ++- src/games/hvacoins/webapi.js | 38 +- src/games/trivia.js | 2 +- src/slack/index.js | 58 +- 14 files changed, 1152 insertions(+), 332 deletions(-) diff --git a/src/auth/index.js b/src/auth/index.js index a35a75a..8b54fc1 100644 --- a/src/auth/index.js +++ b/src/auth/index.js @@ -11,4 +11,4 @@ fetch(url, { headers: headers // credentials: 'user:passwd' }).then(response => response.json()) - .then(json => console.log(json)) + .then(json => console.log('json', json)) diff --git a/src/games/hvacoins/achievements.js b/src/games/hvacoins/achievements.js index e2b0430..6d0f699 100644 --- a/src/games/hvacoins/achievements.js +++ b/src/games/hvacoins/achievements.js @@ -34,6 +34,16 @@ module.exports = { description: 'I like big bets, and that\'s the truth', emoji: 'slot_machine' }, + hugeBets: { + name: 'Make a bet over 100T', + description: `That's so bonk`, + emoji: 'game_die' + }, + mondoBets: { + name: 'Make a bet over 100 Quadrillion', + description: 'H I G H R O L L E R', + emoji: '8ball' + }, ignited: { name: 'You light my fire, baby', description: 'And you pay attention to descriptions!', @@ -45,6 +55,72 @@ module.exports = { description: 'I\'m beginning to feel like a rat god, rat god.', emoji: 'mouse2' }, + mathematician: { + name: 'Own 100 Accountants', + description: 'They rejoice at the appearance of a third digit.', + emoji: 'male-office-worker' + }, + iPod: { + name: 'Own 100 Whales', + description: `With the new iPod, you can hold 100's of songs.`, + emoji: 'whale' + }, + fire100: { + name: 'Own 100 Fires', + description: `Wow, that's bright.`, + emoji: 'fire' + }, + train100: { + name: 'Own 100 Trains', + description: `That's every train in America you've got there.`, + emoji: 'train2' + }, + boom100: { + name: 'Own 100 Boomerangs', + description: `LOUD WOOSHING`, + emoji: 'boomerang' + }, + moon100: { + name: 'Own 100 Moons', + description: `Space Cadet`, + emoji: 'new_moon_with_face' + }, + mirror100: { + name: 'Own 100 Mirrors', + description: `Disco Ball`, + emoji: 'mirror' + }, + butterfly100: { + name: 'Own 100 Butterflies', + description: `Delicate yet powerful.`, + emoji: 'butterfly' + }, + quade100: { + name: 'Own 100 Quades', + description: `Your Ops are super Devved right now.`, + emoji: 'quade' + }, + hvacker100: { + name: 'Own 100 Hvackers', + description: `Did Sage finally make his git repo public?`, + emoji: 'hvacker_angery' + }, + creator100: { + name: 'Own 100 Creators', + description: `_Stern look_`, + emoji: 'question' + }, + smallBusiness100: { + name: 'Own 100 Small Businesses', + description: `Enough to run a small city.`, + emoji: 'convenience_store' + }, + bigBusiness100: { + name: 'Own 100 Big Businesses', + description: `I mean... that's basically all of them.`, + emoji: 'office' + }, + weAllNeedHelp: { name: 'View the \'!coin\' help', description: 'We all need a little help sometimes', @@ -85,5 +161,11 @@ module.exports = { name: 'Take a peek at the lore', description: 'It\'t gotta be worth your time somehow.', emoji: 'books' + }, + + theOtherSide: { + name: 'Die and be reborn', + description: 'You have seen the other side, and do not fear it.', + emoji: 'white_square' } } diff --git a/src/games/hvacoins/buy.js b/src/games/hvacoins/buy.js index 762de0d..7a42bc4 100644 --- a/src/games/hvacoins/buy.js +++ b/src/games/hvacoins/buy.js @@ -1,5 +1,5 @@ const buyableItems = require('./buyableItems') -const { commas, saveGame, setHighestCoins, addAchievement, getUser, singleItemCps, chaosFilter } = require('./utils') +const { commas, setHighestCoins, addAchievement, getUser, singleItemCps, chaosFilter, fuzzyMatcher } = require('./utils') const slack = require('../../slack') const calculateCost = ({ itemName, user, quantity = 1 }) => { @@ -17,9 +17,9 @@ const getItemHeader = user => ([itemName, { baseCost, description, emoji }]) => const itemCps = Math.round(singleItemCps(user, itemName)) return `*${itemName}* :${emoji}: - ${itemCost} HVAC Coins - ${commas(itemCps)} CPS\n_${description}_` } -const canView = highestCoins => ([, item]) => item.baseCost < (highestCoins || 1) * 101 +const canView = (item, highestCoins) => item.baseCost < (highestCoins || 1) * 101 const buyableText = (highestCoins, user) => Object.entries(buyableItems) - .filter(canView(highestCoins)) + .filter(([, item]) => canView(item, highestCoins)) .map(getItemHeader(user)) .join('\n\n') + '\n\n:grey_question::grey_question::grey_question:' + @@ -73,8 +73,8 @@ const buyText2 = (highestCoins, user) => { return ({ text: buyableText(highestCoins, user), blocks: Object.entries(buyableItems) - .filter(canView(highestCoins)) - .map(([itemName, item]) => { + .filter(([, item]) => canView(item, highestCoins)) + .map(([itemName]) => { const cost = calculateCost({ itemName, user, quantity: 1 }) const cps = Math.round(singleItemCps(user, itemName)) return ({ user, itemName, cost, cps }) @@ -90,58 +90,65 @@ const maxQuantity = ({ itemName, user, currentCoins }) => { return quantity } -const buyRoute = async ({ event, say, words, user }) => { - const buying = words[1] +const buyRoute = async ({ event, say, args, user }) => { + const buying = args[0] setHighestCoins(event.user) + const query = event?.text?.startsWith('?b ') || event?.text?.startsWith('?buy ') if (!buying) { const highestCoins = user.highestEver || user.coins || 1 - if (buyableItems.quade.baseCost < highestCoins * 100) { + if (canView(buyableItems.quade, highestCoins)) { addAchievement(user, 'seeTheQuade', say) } await say(buyText2(highestCoins, user)) return } - const buyable = buyableItems[buying] + const matcher = fuzzyMatcher(buying) + const buyable = Object.entries(buyableItems).find(([name]) => matcher.test(name)) if (!buyable) { await say('That item does not exist!') return } + const [buyableName, buyableItem] = buyable let quantity const currentCoins = user.coins - const max = maxQuantity({ itemName: buying, user, currentCoins }) - if (words[2] === 'max') { + const max = maxQuantity({ itemName: buyableName, user, currentCoins }) + if (!args[1]) { + quantity = 1 + } else if (args[1] === 'max') { quantity = max } else { - quantity = Math.round(chaosFilter(parseInt(words[2] || '1'), 0.2, user, max) || 1) + if (query) { + quantity = parseInt(args[1]) + } else { + quantity = Math.round(chaosFilter(parseInt(args[1]), 0.2, user, max) || 1) + } } if (!quantity || quantity < 1) { await say('Quantity must be a positive integer') return } - const realCost = calculateCost({ itemName: buying, user, quantity }) + const realCost = calculateCost({ itemName: buyableName, user, quantity }) + if (query) { + return say(`Buying ${quantity} ${buyableName} would cost you ${commas(realCost)} HVAC`) + } if (currentCoins < realCost) { await say(`You don't have enough coins! You have ${commas(currentCoins)}, but you need ${commas(realCost)}`) return } user.coins -= realCost - user.items[buying] = user.items[buying] || 0 - user.items[buying] += quantity + user.items[buyableName] = user.items[buyableName] || 0 + user.items[buyableName] += quantity - if (buying === 'mouse' && user.items.mouse >= 100) { - addAchievement(user, 'ratGod', say) + if (user.items[buyableName] >= 100) { + addAchievement(user, buyableItems[buyableName].own100Achievement, say) } - if (quantity === 1) { - await say(`You bought one :${buyable.emoji}:`) - } else { - await say(`You bought ${quantity} :${buyable.emoji}:`) - } - - saveGame() + const countString = quantity === 1 ? 'one' : quantity + await say(`You bought ${countString} :${buyableItem.emoji}:`) } const buyButton = async ({ body, ack, say, payload }) => { @@ -153,7 +160,8 @@ const buyButton = async ({ body, ack, say, payload }) => { } const user = getUser(event.user) const words = ['', buying, body.actions[0].text] - await buyRoute({ event, say, words, user }) + const [commandName, ...args] = words + await buyRoute({ event, say, words, args, commandName, user }) const highestCoins = user.highestEver || user.coins || 1 await slack.app.client.chat.update({ channel: body.channel.id, diff --git a/src/games/hvacoins/buyableItems.js b/src/games/hvacoins/buyableItems.js index ab626f8..9283319 100644 --- a/src/games/hvacoins/buyableItems.js +++ b/src/games/hvacoins/buyableItems.js @@ -3,84 +3,98 @@ module.exports = { baseCost: 100, earning: 1, emoji: 'mouse2', - description: 'A mouse to steal coins for you.' + description: 'A mouse to steal coins for you.', + own100Achievement: 'ratGod', }, accountant: { baseCost: 1_100, earning: 8, emoji: 'male-office-worker', - description: 'Legally make money from nothing!' + description: 'Legally make money from nothing!', + own100Achievement: 'mathematician', }, whale: { baseCost: 12_000, earning: 47, emoji: 'whale', - description: 'Someone to spend money on your HVAC Coin mining app.' + description: 'Someone to spend money on your HVAC Coin mining app.', + own100Achievement: 'iPod', }, train: { baseCost: 130_000, earning: 260, emoji: 'train2', - description: 'Efficiently ship your most valuable coins.' + description: 'Efficiently ship your most valuable coins.', + own100Achievement: 'fire100', }, fire: { baseCost: 1_400_000, earning: 1_400, emoji: 'fire', - description: 'Return to the roots of HVAC.' + description: 'Return to the roots of HVAC.', + own100Achievement: 'train100', }, boomerang: { baseCost: 20_000_000, earning: 7_800, emoji: 'boomerang', - description: 'Your coin always seems to come back.' + description: 'Your coin always seems to come back.', + own100Achievement: 'boom100', }, moon: { baseCost: 330_000_000, earning: 44_000, emoji: 'new_moon_with_face', - description: 'Convert dark new-moon energy into HVAC Coins.' + description: 'Convert dark new-moon energy into HVAC Coins.', + own100Achievement: 'mirror100', }, butterfly: { baseCost: 5_100_000_000, earning: 260_000, emoji: 'butterfly', - description: 'Create the exact worldly chaos to bit-flip HVAC Coins into existence on your computer.' + description: 'Create the exact worldly chaos to bit-flip HVAC Coins into existence on your computer.', + own100Achievement: 'butterfly100', }, mirror: { baseCost: 75_000_000_000, earning: 1_600_000, emoji: 'mirror', - description: 'Only by gazing inward can you collect enough Coin to influence the thermostat.' + description: 'Only by gazing inward can you collect enough Coin to influence the thermostat.', + own100Achievement: 'quade100', }, quade: { baseCost: 1_000_000_000_000, earning: 10_000_000, emoji: 'quade', - description: 'Has thumbs capable of physically manipulating the thermostat.' + description: 'Has thumbs capable of physically manipulating the thermostat.', + own100Achievement: 'hvacker100', }, hvacker: { baseCost: 14_000_000_000_000, earning: 65_000_000, emoji: 'hvacker_angery', - description: 'Harness the power of the mad god himself.' + description: 'Harness the power of the mad god himself.', + own100Achievement: 'creator100', }, creator: { baseCost: 170_000_000_000_000, earning: 430_000_000, emoji: 'question', - description: 'The elusive creator of Hvacker takes a favorable look at your CPS.' + description: 'The elusive creator of Hvacker takes a favorable look at your CPS.', + own100Achievement: 'smallBusiness100', }, smallBusiness: { baseCost: 2_210_000_000_000_000, earning: 2_845_000_000, emoji: 'convenience_store', - description: 'The place where the creator of Hvacker goes to work.' + description: 'The place where the creator of Hvacker goes to work.', + own100Achievement: 'bigBusiness100', }, bigBusiness: { baseCost: 26_210_000_000_000_000, earning: 23_650_000_000, emoji: 'office', - description: 'The place where the smallBusiness goes to work.' + description: 'The place where the smallBusiness goes to work.', + own100Achievement: 'ratGod', } } diff --git a/src/games/hvacoins/index.js b/src/games/hvacoins/index.js index 216f217..fe3b3f6 100644 --- a/src/games/hvacoins/index.js +++ b/src/games/hvacoins/index.js @@ -15,7 +15,16 @@ const { getRandomFromArray, chaosFilter, addReactions, - game: { nfts, squad, users, horrors }, setHighestCoins, definitelyShuffle, getCompletedSquadgradeNames + setHighestCoins, + definitelyShuffle, + getCompletedSquadgradeNames, + setKnownUsers, + dayOfYear, + daysSinceEpoch, + userHasCheckedQuackgrade, + fuzzyMatcher, + addCoins, + game: { nfts, squad, users, horrors, stonkMarket }, } = require('./utils') const slack = require('../../slack') const buyableItems = require('./buyableItems') @@ -41,6 +50,8 @@ const settings = require('./settings') // read() // })(); +setKnownUsers(slack.users) + const getUpgradeEmoji = upgrade => upgrade.emoji || buyableItems[upgrade.type].emoji const upgradeText = (user, showOwned = false) => { const userDoesNotHave = ([upgradeName, upgrade]) => hasUpgrade(user, upgrade, upgradeName) === showOwned @@ -62,7 +73,7 @@ const alwaysAccessible = () => true const adminOnly = { hidden: true, condition: ({ event, say }) => { - if (!event.user.startsWith(slack.users.Sage)) { + if (!settings.admins.find(adminName => event.user.startsWith(slack.users[adminName]))) { say('This is an admin-only command!') return false } @@ -75,9 +86,9 @@ const testOnly = { } const dmsOnly = { hidden: false, - condition: async ({ event, say, words }) => { + condition: async ({ event, say, commandName }) => { if (!event.channel_type.includes('im')) { - await say(`Please use ${words[0]} in DMs only!`) + await say(`Please use ${commandName} in DMs only!`) return false } return true @@ -85,23 +96,28 @@ const dmsOnly = { } const prestigeOnly = { hidden: false, - condition: async ({ event, say, words, user }) => { + condition: async ({ event, say, commandName, user }) => { if (!user.prestige) { await say('Sorry, you must have prestiged to access this menu.') } - return user.prestige && await dmsOnly.condition({ event, say, words, user }) + return user.prestige && await dmsOnly.condition({ event, say, commandName, user }) } } +let hiddenCommands = 0 const commands = new Map() let commandHelpText = '' let shortCommandHelpText = 'Use `!help full` to show details for all commands, or `! help` to show for just one.\n```' const defaultAccess = { hidden: false, condition: alwaysAccessible } const command = (commandNames, helpText, action, { hidden, condition } = defaultAccess) => { - console.log(`Initializing command '${commandNames[0]}'`) if (!hidden) { + console.log(`Initializing command '${commandNames[0]}'`) commandHelpText += `\n${commandNames.toString().replace(/,/g, ', ')} - ${helpText}\n` shortCommandHelpText += `\n${commandNames.toString().replace(/,/g, ', ')}` + } else if (condition === adminOnly.condition) { + console.log(`Initializing admin command '${commandNames[0]}'`) + } else { + hiddenCommands++ } if (!condition) { condition = alwaysAccessible @@ -126,11 +142,6 @@ const command = (commandNames, helpText, action, { hidden, condition } = default }) } -const dayOfYear = () => { - const date = new Date() - return ((Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) - Date.UTC(date.getFullYear(), 0, 0)) / 24 / 60 / 60 / 1000) -} - const getShuffleOdds = (offset = 0) => { let int = dayOfYear() - (horrors.hvackerLast - offset) - 2 int = int < 0 ? 0 : int; @@ -146,9 +157,9 @@ const getHorrorMessageOdds = (offset = 0) => { command( ['!odds'], 'Show shuffle odds re: !horror', - async ({ say, words }) => { + async ({ say, args }) => { const percentOrOneIn = odds => `${(odds * 100).toPrecision(3)}%, or about 1 in ${Math.round(1 / odds)}` - if (!words[1]) { + if (!args[0]) { return say( `Current shuffle odds are ${percentOrOneIn(getShuffleOdds())}\n` + //`Current horror message odds are ${percentOrOneIn(getHorrorMessageOdds())}\n` + @@ -157,21 +168,20 @@ command( //`Tomorrow's horror message odds will be ${percentOrOneIn(getHorrorMessageOdds(1))}` ) } - const num = parseAll(words[1], 99) + const num = parseAll(args[0], 99) return say( `Shuffle odds in ${num} days will be ${percentOrOneIn(getShuffleOdds(num))}\n` //`Horror message odds in ${num} days will be ${percentOrOneIn(getHorrorMessageOdds(num))}` ) - }, - adminOnly) + }, adminOnly) command( ['!shuffle'], '!shuffle daysFromNow message', - async ({ say, words }) => { + async ({ say, args }) => { const percentOrOneIn = odds => `${(odds * 100).toPrecision(3)}%, or 1 in ${Math.round(1 / odds)}` - const num = parseAll(words[1], 99) - const [, , ...message] = words + const num = parseAll(args[0], 99) + const [, ...message] = args return say( `Shuffle odds in ${num} days will be ${percentOrOneIn(getShuffleOdds(num))}\n` + `Horror message odds in ${num} days will be ${percentOrOneIn(getHorrorMessageOdds(num))}\n` + @@ -213,11 +223,11 @@ if (settings.horrorEnabled) { }, { hidden: true }) } -const buildHorrorSay = ({ say, event, words, c }) => async message => { +const buildHorrorSay = ({ say, event, commandName, c }) => async message => { const punishmentOffset = event.user === slack.users.Quade ? 0 : 0 const shuffleOdds = getShuffleOdds(punishmentOffset + 99) - if (typeof message === 'string' && words[0] !== '!n' && words[0] !== '!nfts' && c.condition !== adminOnly.condition) { + if (typeof message === 'string' && commandName !== '!n' && commandName !== '!nfts' && c.condition !== adminOnly.condition) { let shuffled = shufflePercent(message, shuffleOdds) if (shuffled.length > 100 && Math.random() < getShuffleOdds()) { const middle = (shuffled.length / 2) + Math.round((Math.random() - 1) * (shuffled.length / 5)) @@ -245,9 +255,101 @@ const buildSayWithPayload = ({ say, event }) => async msg => { }) } +const userHasTheGift = user => userHasCheckedQuackgrade(user, 'theGift') + +command( + ['!!peter-griffin-family-guy'], + 'Delete', + async ({ say, user }) => { + if (user.isDisabled === false) { + return say(`Silly, silly, ${user.name}.\nYou can't just leave us again.`) + } + user.isDisabled = true + //saveGame() + return say('.') + }, { hidden: true }) + +const garble = text => text.split('').map(() => '□').join('') + +const noWinner = 'NO WINNER' + +const getPollWinner = async ({ channel, ts }) => { + try { + const msg = await slack.getMessage({ channel, ts }) + console.log('pollWinner message', JSON.stringify(msg.messages[0])) + let texts = [] + let maxVotes = 0 + for (let i = 1; i < msg.messages[0].blocks.length; i++) { + const block = msg.messages[0].blocks[i] + let [text, votes] = block?.text?.text?.split('\n') || [null, null] + if (!text || !votes) { + continue + } + votes = votes.split('@').length - 1 + console.log(`${votes} votes for:`) + text = text.replace(/^\s*:[a-z]*: /, '') + text = text.replace(/\s+`\d+`$/, '') + console.log(`TEXT: '${text}'`) + console.log(``) + if (votes > maxVotes) { + maxVotes = votes + texts = [text] + } else if (votes === maxVotes) { + texts.push(text) + } + } + console.log('TEXTS', texts) + if (texts.length === 1) { + return [texts[0], false] + } else if (texts.length > 1) { + // There was a tie! + return [getRandomFromArray(texts), true] + } else { + return [noWinner, false] + } + } catch (e) {console.error('getPollWinner() error', e)} +} + +const botMessageHandler = async ({ event, say }) => { + if (event?.text && event.text.toUpperCase().includes(`NFT POLL`)) { + const fiveMinutes = 1000 * 60 * 5 + setTimeout(async () => { + return say(`Poll ends in give minutes!`) + }, fiveMinutes) + + const tenMinutes = fiveMinutes * 2 + setTimeout(async () => { + const [winner, wasTie] = await getPollWinner({ channel: event.channel, ts: event.event_ts }) + if (winner === noWinner) { + return say(`No one voted! Ack!`) + } + if (wasTie) { + await say(`There was a tie! The winner will be randomly selected!`) + } + await say(`The winner is:`) + await say(winner) + }, tenMinutes) + } +} + +command( + ['!getmsg'], + '!getmsg timestamp', + async ({ args, say, user }) => { + try { + const msg = await slack.getMessage({channel: slack.temperatureChannelId, ts: args[0]}) + console.log(JSON.stringify(msg?.messages[0])) + } catch (e) {console.error('!getmsg error', e)} + } +) + const messageHandler = async ({ event, say, isRecycle = false }) => { + if (event?.subtype === 'bot_message') { + return botMessageHandler({ event, say }) + } const words = event?.text?.split(/\s+/) || [] - const c = commands.get(words[0]) + const [commandName, ...args] = words + const c = commands.get(commandName) if (!c && words[0]?.startsWith('!') && event.user !== slack.users.Sage) { return slack.messageSage(`${slack.users[event.user]} tried to use \`${event.text}\`, if you wanted to add that.`) } @@ -256,20 +358,63 @@ const messageHandler = async ({ event, say, isRecycle = false }) => { say = buildSayWithPayload({ say: trueSay, event }) if (settings.horrorEnabled) { - say = buildHorrorSay({say, event, words, c}) + say = buildHorrorSay({say, event, args, commandName, c}) + } + + let user = getUser(event.user) + if (user.isDisabled) { + return + } + + const hauntOdds = 0.03 + const disabledUsers = Object.entries(users).filter(([, user]) => user.isDisabled) + + let haunted = false + if (disabledUsers.length !== 0) { + if (user.expectingPossession) { + user.expectingPossession = false + //saveGame() + haunted = true + const [disabledId, disabledUser] = getRandomFromArray(disabledUsers) + event.user = disabledId + user = getUser(event.user) + if (Math.random() < 0.2) { + say = slack.buildSayPrepend({ say, prepend: `_You feel haunted..._\n_"Hey, it's me, ${(garble(disabledUser.name))}"_\n` }) + } else { + say = slack.buildSayPrepend({ say, prepend: `_You feel haunted..._\n` }) + } + } else if (Math.random() < hauntOdds) { + user.expectingPossession = true + //saveGame() + if (userHasTheGift(user)) { + say = slack.buildSayPrepend({ say, prepend: `_You feel a chill..._\n` }) + } + } + } else { + user.expectingPossession = false } - - const user = getUser(event.user) user.coins = getCoins(event.user) - const canUse = await c?.condition({ event, say, words, user, userId: event.user, isAdmin: event.user.includes(slack.users.Sage) }) + const canUse = await c?.condition({ event, say, words, commandName, args, user, userId: event.user, isAdmin: event.user.includes(slack.users.Sage) }) if (!canUse) { + // const matcher = fuzzyMatcher(commandName) + // for (const key of commands.keys()) { + // if (matcher.test(key)) { + // const fetched = commands.get(key) + // if (!fetched.hidden && await fetched.condition({ event, say: () => {}, words, commandName, args, user, userId: event.user, isAdmin: event.user.includes(slack.users.Sage) })) { + // //return say(`Did you mean '${key}'?`) + // } + // } + // } // await say(`Command '${words[0]}' not found`) return } user.interactions = user.interactions || 0 user.interactions += 1 - if (user.interactions > 999) { + if (user.interactions === 1000) { addAchievement(user, 'itsOverNineHundred', say) + } else if (user.interactions === 10000) { + // TODO Add achievement for this + // addAchievement(user, 'itsOverNineHundred', say) } const hour = new Date().getHours() @@ -277,7 +422,7 @@ const messageHandler = async ({ event, say, isRecycle = false }) => { addAchievement(user, 'hvackerAfterDark', say) } - if (words[1] === 'help') { + if (args[0] === 'help') { await say(c.commandNames.map(name => `\`${name}\``).join(', ') + ': ' + c.helpText) if (c.commandNames.includes('!coin')) { addAchievement(user, 'weAllNeedHelp', say) @@ -285,16 +430,20 @@ const messageHandler = async ({ event, say, isRecycle = false }) => { return } - await c.action({ event, say, trueSay, words, user, userId: event.user }) + await c.action({ event, say, trueSay, words, args, commandName, user, userId: event.user, haunted }) if (!isRecycle) { const userQuackgrades = (user.quackUpgrades?.lightning || []).map(name => quackStore[name]) - const defaultOdds = 0.01 + const defaultOdds = 0.005 const odds = userQuackgrades.reduce((total, upgrade) => upgrade.effect(total), defaultOdds) - console.log(odds) + //console.log(odds) if (Math.random() < odds) { - setTimeout(() => lightning({ event, say, trueSay, words, user }), 10000) + if (userHasTheGift(user) && Math.random() < 0.3) { + await say(`_The air feels staticy..._`) + } + setTimeout(() => lightning({ channel: event.channel, say, trueSay, words, user }), 10000) } } + saveGame() } slack.onReaction(async ({ event }) => { @@ -328,15 +477,15 @@ slack.onReaction(async ({ event }) => { text: payload.event.text, user: event.user }, say: editingSay, isRecycle: true }) - } catch(e) {console.error(e)} + } catch(e) {console.error('refresh error', e)} } }) const strikes = {} -const lightning = async ({ event, user }) => { - const msToBottle = chaosFilter(5000, 1, user, Infinity, 2500) +const lightning = async ({ user, ms = 5000, channel, multiplier = 1 }) => { + const msToBottle = chaosFilter(ms, 1, user, Infinity, ms / 2) const message = await slack.app.client.chat.postMessage({ - channel: event.channel, + channel, text: ':zap: Lightning strike!', blocks: [ { @@ -358,34 +507,67 @@ const lightning = async ({ event, user }) => { } ] }) - strikes[message.ts] = true + strikes[message.ts] = multiplier setTimeout(async () => { if (!strikes[message.ts]) { return } delete strikes[message.ts] - await slack.app.client.chat.update({ - channel: event.channel, + await slack.app.client.chat.delete({ + channel: message.channel, ts: message.ts, - text: 'Lightning struck, but you missed it!', - blocks: [] }) - await slack.messageSage(`They didn't bottle it!`) + // await slack.messageSage(`${user.name} failed to bottle some lighting!`) }, msToBottle) } +const dedicatedPlayers = [ + slack.users.Sage, + slack.users.Houston, + slack.users.Adam, + slack.users.Fernando, +] + +command( + ['!bolt'], + 'Send a lighting strike to the given player.', + async({ args, say, }) => { + const targetId = idFromWord(args[0]) + await lightning({ user: getUser(targetId), ms: 15000, channel: targetId}) + return say(`Sent a bolt of lighting to <@${targetId}>`) + }, adminOnly) + +command( + ['!storm'], + 'Send a lighting strike to known dedicated players.', + async ({ say, args }) => { + // await dedicatedPlayers.forEach(async player => { + // await lightning({ + // user: getUser(player), + // ms: 30000, + // channel: player + // }) + // }) + const targetId = idFromWord(args[0]) + for (let i = 0; i < 10; i++) { + setTimeout(async () => await lightning({ user: getUser(targetId), ms: 1600, channel: targetId, multiplier: 0.02}), i * 1500) + } + return say(`Sent a lighting storm to <@${targetId}>`) + // return say(`Sent a bolt of lighting to the dedicated players`) + }, adminOnly) + slack.app.action('lightningStrike', async ({ body, ack }) => { if (!strikes[body.message.ts]) { await ack() return } - delete strikes[body.message.ts] const c = getCoins(body.user.id) const user = getUser(body.user.id) const secondsOfCps = seconds => Math.floor(getCPS(user) * seconds) let payout = Math.floor(c * 0.10) + secondsOfCps(60 * 30) - payout = 500 + chaosFilter(payout, 1, user) - user.coins = c + payout + payout = (500 + chaosFilter(payout, 1, user)) * strikes[body.message.ts] + addCoins(user, (c + payout) - user.coins) + delete strikes[body.message.ts] saveGame() await slack.app.client.chat.update({ @@ -395,6 +577,7 @@ slack.app.action('lightningStrike', async ({ body, ack }) => { blocks: [] }) await ack() + return slack.messageSage(`Lighting bottled by <@${body.user.id}>`) }) slack.onMessage(async msg => { @@ -405,39 +588,6 @@ slack.onMessage(async msg => { } }) -const chadSpeak = -` ⡜ -⠘⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀ ⠀ ⣀⠴⠊ -⠑⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀ ⣀⠴⠊⠁ -⠘⡀ ⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⣀⠴⠊ -⠈⠢⢄ ⣀⣀⡀⠤⠄⠒⠈ -⠘⣀⠄⠊⠁ -⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠛⠛⠛⠋⠉⠈⠉⠉⠉⠉⠛⠻⢿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⡿⠋⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⢿⣿⣿⣿⣿ -⣿⣿⣿⣿⡏⣀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣤⣤⣄⡀⠀⠀⠀⠀⠀⠀⠀⠙⢿⣿⣿ -⣿⣿⣿⢏⣴⣿⣷⠀⠀⠀⠀⠀⢾⣿⣿⣿⣿⣿⣿⡆⠀⠀⠀⠀⠀⠀⠀⠈⣿⣿ -⣿⣿⣟⣾⣿⡟⠁⠀⠀⠀⠀⠀⢀⣾⣿⣿⣿⣿⣿⣷⢢⠀⠀⠀⠀⠀⠀⠀⢸⣿ -⣿⣿⣿⣿⣟⠀⡴⠄⠀⠀⠀⠀⠀⠀⠙⠻⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀⠀⣿ -⣿⣿⣿⠟⠻⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠶⢴⣿⣿⣿⣿⣿⣧⠀⠀⠀⠀⠀⠀⣿ -⣿⣁⡀⠀⠀⢰⢠⣦⠀⠀⠀⠀⠀⠀⠀⠀⢀⣼⣿⣿⣿⣿⣿⡄⠀⣴⣶⣿⡄⣿ -⣿⡋⠀⠀⠀⠎⢸⣿⡆⠀⠀⠀⠀⠀⠀⣴⣿⣿⣿⣿⣿⣿⣿⠗⢘⣿⣟⠛⠿⣼ -⣿⣿⠋⢀⡌⢰⣿⡿⢿⡀⠀⠀⠀⠀⠀⠙⠿⣿⣿⣿⣿⣿⡇⠀⢸⣿⣿⣧⢀⣼ -⣿⣿⣷⢻⠄⠘⠛⠋⠛⠃⠀⠀⠀⠀⠀⢿⣧⠈⠉⠙⠛⠋⠀⠀⠀⣿⣿⣿⣿⣿ -⣿⣿⣧⠀⠈⢸⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠟⠀⠀⠀⠀⢀⢃⠀⠀⢸⣿⣿⣿⣿ -⣿⣿⡿⠀⠴⢗⣠⣤⣴⡶⠶⠖⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡸⠀⣿⣿⣿⣿ -⣿⣿⣿⡀⢠⣾⣿⠏⠀⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠛⠉⠀⣿⣿⣿⣿ -⣿⣿⣿⣧⠈⢹⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⣿⣿⣿⣿ -⣿⣿⣿⣿⡄⠈⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⣴⣾⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣦⣄⣀⣀⣀⣀⠀⠀⠀⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠀⠀⠀⠙⣿⣿⡟⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇⠀⠁⠀⠀⠹⣿⠃⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⢐⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⠿⠛⠉⠉⠁⠀⢻⣿⡇⠀⠀⠀⠀⠀⠀⢀⠈⣿⣿⡿⠉⠛⠛⠛⠉⠉ -⡿⠋⠁⠀⠀⢀⣀⣠⡴⣸⣿⣇⡄⠀⠀⠀⠀⢀⡿⠄⠙⠛⠀⣀⣠⣤⣤⠄` - command( ['!cleanusers'], 'Calls getUser() on all users, ensuring a valid state.', @@ -558,12 +708,12 @@ command( ['!setpw'], 'Set your api password. May not contain spaces. *This is not secure!*\n' + ' To use, say !setpw your_password', - async ({ say, words, user }) => { - if (words[2]) { + async ({ say, args, user }) => { + if (args[1]) { return say(`Your password may not contain spaces!`) } - user.pwHash = webapi.makeHash(words[1]) - await saveGame() + user.pwHash = webapi.makeHash(args[0]) + //saveGame() await say(`Password encoded as ${user.pwHash}`) } , { hidden: true }) @@ -576,8 +726,8 @@ const getHvackerHelpCost = () => { command( ['!!help'], 'Help someone in need.', - async ({ say, words, event, user }) => { - if (words[1] !== 'hvacker' && words[1] !== `<@${slack.users.Hvacker}>`) { + async ({ say, args, event, user }) => { + if (args[0] !== 'hvacker' && args[0] !== `<@${slack.users.Hvacker}>`) { return } @@ -593,21 +743,21 @@ command( user.coins -= cost horrors.hvackerHelp += 1 horrors.hvackerLast = dayOfYear() - saveGame() + //saveGame() await say('I feel a bit better. Thank you...') }, { hidden: true }) command( ['!help', '!h'], 'List available commands', - async ({ say, words, user }) => { - if (settings.horrorEnabled && (words[1] === 'hvacker' || words[1] === `<@${slack.users.Hvacker}>`)) { + async ({ say, args, user }) => { + if (settings.horrorEnabled && (args[0] === 'hvacker' || args[0] === `<@${slack.users.Hvacker}>`)) { const cost = getHvackerHelpCost() const postfix = user.coins < cost ? `You don't have enough coins to help right now.` : `Say \`!!help hvacker\` to confirm.` await say(`_I need ${commas(cost)} Coins. Please..._\n${postfix}`) return } - if (words[1] === 'full') { + if (args[0] === 'full') { return say('```' + commandHelpText + '```') } return say(shortCommandHelpText + '```') @@ -626,9 +776,9 @@ const removeAchievement = async (user, name, say) => { command( ['!rach'], 'Remove achievement', - async ({ say, words }) => { - const achName = words[1] - const target = idFromWord(words[2]) + async ({ say, args }) => { + const achName = args[0] + const target = idFromWord(args[1]) await removeAchievement(getUser(target), achName, say) }, adminOnly) @@ -654,21 +804,14 @@ command( } ) -const emojiLine2 = (itemName, countOwned) => - ((emoji = buyableItems[itemName].emoji) => countOwned < 5 - ? `:${emoji}:`.repeat(countOwned) - : `:${emoji}: x${countOwned}`)() - -const emojiLine3 = (itemName, countOwned) => - (countOwned < 5 - ? s => s.repeat(countOwned) - : s => s + ` x${countOwned}`)(buyableItems[itemName].emoji) - const emojiLine = (itemName, countOwned) => countOwned < 5 ? `:${buyableItems[itemName].emoji}:`.repeat(countOwned) : `:${buyableItems[itemName].emoji}: x${countOwned}` + const collection = user => - Object.entries(user?.items || {}) + Object.entries(buyableItems) + .map(([itemName]) => [itemName, user?.items[itemName] || 0]) + .filter(([, countOwned]) => countOwned > 0) .map(([itemName, countOwned]) => emojiLine(itemName, countOwned) + ' - ' + commas(getItemCps(user, itemName)) + ' cps') .join('\n') @@ -740,16 +883,16 @@ const doMine = async ({ user, userId, say }) => { const secondsOfCps = seconds => Math.floor(getCPS(user) * seconds) let diff let prefix - if (random > 0.9937) { + if (random > 0.9947) { diff = 500 + Math.floor(c * 0.10) + secondsOfCps(60 * 30) prefix = `:gem: You found a lucky gem worth ${commas(diff)} HVAC!\n` addAchievement(user, 'luckyGem', say) await slack.messageSage(`${slack.users[userId]} FOUND A LUCKY GEM COIN WORTH ${commas(diff)} HVAC!`) - } else if (random > 0.976) { + } else if (random > 0.986) { diff = 50 + Math.floor(c * 0.025) + secondsOfCps(60) prefix = `:goldbrick: You found a lucky gold coin worth ${commas(diff)} HVAC!\n` addAchievement(user, 'goldBrick', say) - } else if (random > 0.95) { + } else if (random > 0.94) { diff = 10 + Math.floor(c * 0.01) + secondsOfCps(10) prefix = `:money_with_wings: You found a lucky green coin worth ${commas(diff)} HVAC!\n` addAchievement(user, 'greenCoin', say) @@ -757,8 +900,8 @@ const doMine = async ({ user, userId, say }) => { prefix = 'You mined one HVAC.\n' diff = 1 } - user.coins = c + diff - saveGame() + addCoins(user, diff) + //saveGame() return `${prefix}You now have ${commas(user.coins)} HVAC coin${c !== 1 ? 's' : ''}. Spend wisely.` } @@ -773,21 +916,53 @@ command( command( ['!as'], 'Run commands as another user.', - async ({ event, words, trueSay }) => { - const [bangAs, impersonating, ...newWords] = words + async ({ event, args, trueSay }) => { + const [impersonating, ...newWords] = args event.user = idFromWord(impersonating) + const isDisabled = users[event.user].isDisabled + users[event.user].isDisabled = false event.text = newWords.join(' ') await messageHandler({ event, say: trueSay, isRecycle: false}) + users[event.user].isDisabled = isDisabled }, adminOnly) +command( + ['!react'], + '!react ', + async ({ args }) => { + const [emoji, timestamp] = args + console.log('args:', args) + try { + await slack.app.client.reactions.add({ + channel: slack.temperatureChannelId, + timestamp, + name: emoji.replace(/:/g, '') + }) + } catch (e) { + console.error('!react error', e) + } + }, adminOnly) + +command( + ['!enable'], + 'Enable the given user', + async ({ args }) => { + const user = getUser(idFromWord(args[0])) + if (user.isDisabled) { + user.isDisabled = false + //saveGame() + addAchievement(user, 'theOtherSide', slack.messageSage) + await slack.postToTechThermostatChannel(`_${user.name} has returned..._`) + } + } +) + command( ['!g', '!gamble'], 'Gamble away your HVAC\n' + ' To use, say \'gamble coin_amount\' or \'!gamble all\'', - async ({ event, say, words, user }) => { - - const [, ...wager] = words - const requestedWager = parseAll(wager.join(' '), user.coins) + async ({ say, args, user }) => { + const requestedWager = parseAll(args.join(' '), user.coins) const n = (chaosFilter(requestedWager, 0.2, user, user.coins) + requestedWager) / 2 if (!n || n < 0) { return say(`Invalid number '${n}'`) @@ -798,6 +973,12 @@ command( if (n >= 100_000_000_000) { addAchievement(user, 'bigBets', say) } + if (n >= 100_000_000_000_000) { + addAchievement(user, 'hugeBets', say) + } + if (n >= 100_000_000_000_000_000) { + addAchievement(user, 'mondoBets', say) + } user.coins -= n let outcome if (Math.random() > 0.5) { @@ -807,29 +988,46 @@ command( outcome = 'lost' } console.log(`They ${outcome}`) - saveGame() + //saveGame() await say(`You bet ${commas(n)} coins and ${outcome}! You now have ${commas(user.coins)}.`) - if (outcome === 'lost') { - switch (event.user) { - case slack.users.Quade: - await say(':trimedary_anim:') - break; - case slack.users.Houston: - await say(':fortnite:') - break; - } + if (outcome === 'lost' && user.lostBetMessage) { + await say(user.lostBetMessage) + } else if (outcome === 'won' && user.wonBetMessage) { + await say(user.wonBetMessage) } } ) +command( + ['!setloss'], + '!setloss ', + async ({ args, user, say }) => { + const emoji = args[0] + if (!emoji || !emoji.startsWith(':') || !emoji.endsWith(':') || emoji.includes(' ') || emoji.includes('\n')) { + return say(`Argument must be a single emoji!`) + } + user.lostBetMessage = emoji + }, {hidden: true}) + +command( + ['!setwon'], + '!setwon ', + async ({ args, user, say }) => { + const emoji = args[0] + if (!emoji || !emoji.startsWith(':') || !emoji.endsWith(':')) { + return say(`Argument must be a single emoji!`) + } + user.wonBetMessage = emoji + }, {hidden: true}) + command( ['!buynft', '!bn'], 'Acquire high-quality art\n' + ' To use, say \'!buynft nft_name\'', - async ({ event, say, words, user }) => { - const nft = nfts.find(n => n.name.toLowerCase() === words[1]) + async ({ event, say, args, user }) => { + const nft = nfts.find(n => n.name.toLowerCase() === args[0]) if (!nft) { - const suffix = words[1]?.match(/[^a-z0-9_]/i) ? '. And I\'m unhackable, so cut it out.' : '' + const suffix = args[0]?.match(/[^a-z0-9_]/i) ? '. And I\'m unhackable, so cut it out.' : '' return say('No NFT with that name found' + suffix) } if (nft.owner) { @@ -842,7 +1040,7 @@ command( user.coins -= nft.price nft.owner = event.user - saveGame() + //saveGame() await say('You bought ' + nft.name + '!') } ) @@ -854,14 +1052,26 @@ command( await say(upgradeText(user, true)) }, dmsOnly) +const upgradeText2 = user => { + const userDoesNotHave = ([upgradeName, upgrade]) => !hasUpgrade(user, upgrade, upgradeName) + const userMeetsCondition = ([, upgrade]) => upgrade.condition(user, getCompletedSquadgradeNames()) + return ({ + text: upgradeText(user, false), + blocks: Object.entries(upgrades) + .filter(userDoesNotHave) + .filter(userMeetsCondition) + .map(([upgradeName]) => upgradeBlock(upgradeName)) + }) +} + command( ['!upgrade', '!u'], 'Improve the performance of your HVAC-generators.\n' + ' Say \'!upgrade\' to list available upgrades, or \'!upgrade upgrade_name\' to purchase.', - async ({ say, words, user }) => { - const upgradeName = words[1] + async ({ say, args, user }) => { + const upgradeName = args[0] if (!upgradeName) { - return say(upgradeText(user)) + return say(upgradeText2(user)) } const upgrade = upgrades[upgradeName] if (!upgrade) { @@ -882,10 +1092,49 @@ command( } user.coins -= upgrade.cost user.upgrades[upgrade.type].push(upgradeName) - await saveGame() + //saveGame() await say(`You bought ${upgradeName}!`) }, dmsOnly) +const upgradeBlock = upgradeName => ({ + type: 'section', + text: { + type: 'mrkdwn', + text: `${upgradeName} :${(buyableItems[upgrades[upgradeName].type].emoji)}: - H${commas(upgrades[upgradeName].cost)}\n_${upgrades[upgradeName].description}_` + }, + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'Buy', + emoji: true + }, + value: 'upgrade_' + upgradeName, + action_id: 'upgrade_' + upgradeName + } +}) + +const upgradeButton = async ({ body, ack, say, payload }) => { + await ack() + const upgrade = payload.action_id.substring(8) + console.log(`upgradeButton ${upgrade} clicked`) + const event = { + user: body.user.id + } + const user = getUser(event.user) + const words = ['!upgrade', upgrade] + const [commandName, ...args] = words + await commands.get('!u').action({ event, say, words, args, commandName, user }) + //const highestCoins = user.highestEver || user.coins || 1 + await slack.app.client.chat.update({ + channel: body.channel.id, + ts: body.message.ts, + ...upgradeText2(user) + }) +} + +Object.keys(upgrades).forEach(upgradeName => slack.app.action('upgrade_' + upgradeName, upgradeButton)) + const getCurrentSquadgrade = () => { const current = Object.entries(squadUpgrades).find(squadIsMissing) if (!current) { @@ -923,8 +1172,8 @@ command( 'Buy upgrades that help the whole team.\n' + ' Say !squad to list squad upgrades.\n' + ' Say \'!squad contrib_amount\' to make progress toward the current upgrade.', - async ({ say, words, user }) => { - if (!words[1]) { + async ({ say, args, user }) => { + if (!args[0]) { return say(squadText()) } const current = getCurrentSquadgrade() @@ -932,13 +1181,12 @@ command( return say('No squadgrades are currently available') } const currentCoins = user.coins - let [, ...amountWords] = words - let amount = parseAll(amountWords.join(' '), currentCoins) + let amount = parseAll(args.join(' '), currentCoins) if (amount > currentCoins) { return say(`You don't have that much HVAC! You have ${currentCoins}.`) } if (!amount || amount < 1) { - return say(`Invalid amount: '${words[1]}'`) + return say(`Invalid amount: '${args[0]}'`) } if (amount > squad.upgrades[current.name]) { amount = squad.upgrades[current.name] @@ -954,17 +1202,21 @@ command( } else { status = ` Current status:\n\n${squadText()}` } - saveGame() + if (user.squadGradeContributions > 10_000_000_000_000_000) { + //addAchievement(user, '') + } + //saveGame() await say(`Thank you for your contribution of ${commas(amount)} HVAC!${status}`) } ) const buyRoute = require('./buy') command( - ['!buy', '!b'], + ['!buy', '!b', '?b', '?buy'], 'Buy new items to earn HVAC with\n' + ' Use without arguments to list all available items.\n' + - ' Say \'!buy item_name optional_quantity\' to make your purchase.', + ' Say \'!buy item_name optional_quantity\' to make your purchase.\n' + + ' Say \'?b item_name optional_quantity\' to check how much your purchase will cost.', buyRoute ) @@ -985,10 +1237,10 @@ command( ) command( - ['!check', '!ch'], + ['!check', '!ch', '!ᴄheck', '!ᴄh'], 'Check how many coins another player has', - async ({ say, words }) => { - const targetId = idFromWord(words[1]) + async ({ say, args, event }) => { + const targetId = idFromWord(args[0]) if (!targetId) { return say('Target must be a valid @') } @@ -998,8 +1250,13 @@ command( const humanMembers = members.filter(name => name.length === 11) return say(`Hvacker owns ${humanMembers.length} souls.`) } - const coins = getCoins(targetId) - await say(`<@${targetId}> has ${commas(coins, words[2] === 'exact')} HVAC.`) + const user = getUser(targetId) + if (user.isDisabled) { + return say(`<@${targetId}> is no longer with us.`) + } + const fakeC = 'ᴄ' + const coins = event.text[1] === fakeC ? 0 : getCoins(targetId) + await say(`<@${targetId}> has ${commas(coins, args[1] === 'exact')} HVAC.`) } ) @@ -1007,8 +1264,11 @@ command( ['!gift', '!give', '!gi'], 'Donate coins to a fellow player\n' + ' Send coins by saying \'!gift @player coin_amount\'', - async ({ words, say, user }) => { - let [, target, ...amountText] = words + async ({ args, say, user, haunted }) => { + if (haunted) { + return say(`!give doesn't work while you're haunted.`) + } + let [target, ...amountText] = args amountText = amountText.join(' ') const amount = parseAll(amountText, user.coins) const targetId = idFromWord(target) @@ -1059,10 +1319,9 @@ command( command( ['!gimme'], 'Give self x coins', - async ({ say, words, user }) => { - const increase = parseInt(words[1].replace(/,/g, '')) - user.coins += increase - user.coinsAllTime += increase + async ({ say, args, user }) => { + const increase = parseInt(args[0].replace(/,/g, '')) + addCoins(user, increase) await say(`You now have ${user.coins} HVAC.`) }, testOnly) @@ -1070,36 +1329,78 @@ command( ['!nfts', '!nft', '!n'], 'Show NFTs in the museum\n' + ' Call with no arguments to list all NFTs, or \'!nft nft_name\' to show a particular one.', - async ({ say, words }) => { + async ({ say, args }) => { const owner = nft => `Owner: *${slack.users[nft.owner] || 'NONE'}*` const nftDisplay = nft => `_"${nft.name}"_\n\n${nft.description}\n\n${commas(nft.price)} HVAC.\n\n${nft.picture}\n\n${owner(nft)}` - const filter = words[1] ? nft => words[1]?.toLowerCase() === nft.name : null + const matcher = fuzzyMatcher(args[0] || '') await say(nfts - .filter(filter || (() => true)) + .filter(({name}) => matcher.test(name)) .map(nftDisplay) - .join('\n-------------------------\n') || (filter ? 'No NFTs with that name exist' : 'No NFTs currently exist.') + .join('\n-------------------------\n') || (args[0] ? 'No matching NFTs found' : 'No NFTs currently exist.') ) } ) +let emojiLevel = 1 +const buildPEmoji = name => { + const ret = [name, emojiLevel] + emojiLevel *= 2 + return ret +} +const prestigeEmojis = [ + buildPEmoji('rock'), + buildPEmoji('wood'), + buildPEmoji('seedling'), + buildPEmoji('evergreen_tree'), + buildPEmoji('hibiscus'), + buildPEmoji('thunder_cloud_and_rain'), + buildPEmoji('rainbow'), + buildPEmoji('star'), + buildPEmoji('dizzy'), + buildPEmoji('sparkles'), + buildPEmoji('star2'), + buildPEmoji('stars'), + buildPEmoji('comet'), + buildPEmoji('night_with_stars'), + buildPEmoji('milky_way'), + buildPEmoji('eye'), +] + const prestigeEmoji = user => { const p = user.prestige || 0 if (!p) { return '' } - if (p < 10) { - return ':star:' - } - if (p < 20) { - return ':star2:' - } - if (p < 30) { - return ':stars:' + let e = '' + for (const [emoji, requiredLevel] of prestigeEmojis) { + if (p < requiredLevel) { + break + } + e = emoji } + return e && `:${e}:` } +command( + ['!pemojis', '!pemoji'], + `Show the emoji for each prestige level you've been through.`, + async ({ say, user, event, args }) => { + let p = user.prestige || 0 + if (event.user === slack.users.Sage && args[0] === 'all') { + p = 99999999 + } + let message = '' + for (const [emoji, requiredLevel] of prestigeEmojis) { + if (requiredLevel > p) { + break + } + message += `${requiredLevel} => :${emoji}:\n` + } + return say(message) + }, prestigeOnly) + command( ['!leaderboard', '!lb'], 'Show the top HVAC-earners, ranked by prestige, then CPS', @@ -1110,7 +1411,7 @@ command( let index = 1 await say( Object.entries(users) - .filter(([id, user]) => Object.entries(user.items).length > 0 || user.prestige) + .filter(([, user]) => !user.isDisabled && (Object.entries(user.items).length > 0 || user.prestige)) .sort(([id, user1], [id2, user2]) => { const leftPrestige = getUser(id).prestige const rightPrestige = getUser(id2).prestige @@ -1143,6 +1444,7 @@ const oneShot = (name, helpText, message, achievementName) => { }, { hidden: true }) } +oneShot('!peter-griffin-family-guy', 'Good stuff great comedy.', `Are you sure?\nThis will permanently delete all of your progress and remove you from the game.\nSay !!peter-griffin-family-guy to confirm.`) oneShot('!santa', 'Ho ho ho!', '') oneShot('!sugma', 'Not very original.', ':hvacker_angery:') oneShot('!pog', 'One poggers hvacker', '') @@ -1164,10 +1466,11 @@ command( ' The second line should be the description of the pieces\n' + ' the picture is everything after the second line', async ({ event }) => { - const [, name, price] = event.text.substring(0, event.text.indexOf('\n')).split(' ') + let [, name, ...price] = event.text.substring(0, event.text.indexOf('\n')).split(' ') const rest = event.text.substring(event.text.indexOf('\n') + 1) const desc = rest.substring(0, rest.indexOf('\n')) const picture = rest.substring(rest.indexOf('\n') + 1) + price = price.join(' ') const newNft = { name, price: parseInt(price.replace(/,/g, '')), @@ -1177,7 +1480,6 @@ command( } nfts.push(newNft) console.log('addedNft', newNft) - return saveGame() }, adminOnly) command( @@ -1189,8 +1491,7 @@ command( command( ['!!prestige'], 'Confirm your prestige activation.', - prestige.prestigeConfirmRoute -) + prestige.prestigeConfirmRoute, { hidden: true }) command( ['!quack', '!quackstore'], @@ -1218,38 +1519,38 @@ command( command( ['!ss'], 'Show the status for another player: !ss @player', - async ({ words, say }) => { - const target = words[1] + async ({ args, say }) => { + const target = args[0] const targetId = idFromWord(target) const targetUser = getUser(targetId) await say( `${target} is currently earning \`${commas(getCPS(targetUser))}\` HVAC Coin per second.\n\n` + `They have ${commas(getCoins(targetId))} HVAC Coins\n\n` + `${collection(targetUser)}\n\n` + - `${Object.entries(targetUser?.items || {}).reduce((total, [name, count]) => total + count, 0)} total items\n\n` + `${Object.entries(targetUser?.items || {}).reduce((total, [, count]) => total + count, 0)} total items\n\n` ) }, adminOnly) const exec = require('child_process').exec -const fs = require('fs') const { createReadStream } = require('fs') command( ['!pl'], '!pl `code`\n\n' + 'A very very stupid lisp implementation.', async ({ event, say }) => { - let code = ' '// = '(def iLoveHvacker "9jklFUlbnd38bCrrU9765FhN") ' + return + let code = ' (def sys 0)'// = '(def iLoveHvacker "9jklFUlbnd38bCrrU9765FhN") ' code += event.text.substring(4) .replace(/`/g, '') .replace(/</g, '<') code += ' ' // console.log('PL CODE:', code) - const fileName = '/home/sagevaillancourt/git/hvacker/pl/' + event.user + (new Date().toLocaleString().replace(/[^a-z0-9]/gi, '_')) - fs.writeFileSync(fileName, code) + //const fileName = '/home/sagevaillancourt/git/hvacker/pl/' + event.user + (new Date().toLocaleString().replace(/[^a-z0-9]/gi, '_')) + //fs.writeFileSync(fileName, code) - const command = `/home/sagevaillancourt/projects/pebblisp/src/pl ${fileName}` - console.log(command) + const command = `/home/sagevaillancourt/projects/pebblisp/src/pl '${code}'` + console.log('pl command:', command) const child = exec(command) let result = '```\n' let errors = '' @@ -1275,13 +1576,13 @@ command( result += '```' + err + (errors && 'stderr:\n```\n' + errors.substring(0, 200) + '```') await say(result) }) - }, { hidden: true, condition: ({ event }) => event.user.startsWith(slack.users.Sage) }) + }, adminOnly) command( ['!steal', '!sagesteal'], 'Highly illegal', - async ({ event, words, say, user }) => { - const [, target] = words + async ({ event, args, say, user }) => { + const [target] = args const amount = user.coins / 100 if (!amount || amount < 0) { return @@ -1302,7 +1603,6 @@ command( await say(`Stealing is wrong. Gave ${commas(amount)} of your HVAC to ${slack.users[targetId]}`) }, { hidden: true }) -// TODO command( ['!lotto'], 'Once per day, try for a big win!', @@ -1314,15 +1614,15 @@ command( } let msg if (Math.random() < 0.01) { - const prize = user.coinsAllTime / 20 + const prize = 5000 + (user.coinsAllTime / 20) msg = `Ayyyyy, we have a winner! You win ${commas(prize)} HVAC!` - user.coins += prize + addCoins(user, prize) } else { msg = `Sorry pal, today's not your lucky day.` } await say(msg) user.lastLotto = currentDate - saveGame() + //saveGame() }, { hidden: true }) command( @@ -1335,28 +1635,31 @@ command( command( ['!giveach'], '!giveach @player ach_name', - async ({ words, say, user }) => { - addAchievement(user, words[2], say) - saveGame() + async ({ args, say, user }) => { + addAchievement(user, args[1], say) + //saveGame() }, adminOnly) command( ['!whois'], '!whois player_id', - async ({ words, say }) => say(`<@${words[1]}>`), + async ({ args, say }) => say(`<@${args[0]}>`), adminOnly) command( ['!ngift'], '!ngift player_id nft_name', - async ({ event, words, say }) => { - const targetId = idFromWord(words[1]) + async ({ event, args, say, haunted }) => { + if (haunted) { + return say(`!ngift doesn't work while you're haunted.`) + } + const targetId = idFromWord(args[0]) if (!targetId) { return say('Please specify a valid @ target!') } - const nft = nfts.find(nft => nft.name === words[2]) + const nft = nfts.find(nft => nft.name === args[1]) if (!nft) { - return say(`There is not NFT named "${words[2]}"!`) + return say(`There is not NFT named "${args[1]}"!`) } if (nft.owner !== event.user) { return say(`You do not own "${nft.name}"!`) @@ -1369,14 +1672,195 @@ command( '!deletetest', async () => { delete users[slack.testId] - saveGame() + //saveGame() }, adminOnly) +const stonkPatterns = { + // Change %: ~1.25 + duk: [ + 2.36, -1.69, -1.93, 4.95, -0.99, -0.89, + 0.90, 2.21, 1.29, -4.93, 1.90, 0.90, + 0.37, -0.07, 2.85, 0.25, 0.40, 1.58, + 3.10, 1.37, 0.68, -2.57, -1.80, -0.21, + -2.80, -0.11, 0.31, -0.25, 1.56, 1.97, + -0.44, -6.28, 2.67, 2.85, 5.37, 2.04, + 3.01, 1.75, -3.53, 0.38, -7.63, -1.89, + -0.83, -2.27, 6.14, -2.05, 0.84, -3.22, + 2.34, -1.79, 1.26, -2.48, -5.73, 0.37, + -4.63, 5.31, 1.23 + ], + // Change %: ~13.30 + quak: [ + 3.21, -0.81, 0.84, 0.17, 2.18, -0.19, + -2.57, 1.26, 0.44, -2.28, 0.11, -0.21, + 1.16, 4.07, -0.28, 0.61, 1.76, -0.90, + 1.14, -2.37, 0.96, -2.35, -3.42, -1.21, + 0.00, 2.10, -0.18, 3.42, -1.71, -0.32, + -1.77, -1.46, 0.87, 0.60, 1.63, 1.51, + -0.07, 1.87, -0.38, -0.44, -1.02, 1.70, + -0.46, -4.32, 0.06, 0.41, 5.03, 0.84, + -1.03, 3.88, 3.38, 2.24, -0.43, -0.50, + -3.61, 0.32 + ], + // Change %: ~0.77 + honk: [ + -0.30, 3.73, 11.27, -7.71, -6.81, 1.15, + 3.55, -3.42, 8.51, -3.22, 8.20, 0.19, + 0.76, -2.00, 9.63, 0.87, 3.14, -3.76, + -2.27, 3.42, 2.39, 4.51, -0.35, -0.95, + -6.64, -6.88, 8.90, 0.42, -0.04, -3.33, + 1.85, 4.16, -5.26, -7.24, 5.35, 0.46, + 5.16, -0.10, -5.24, 5.34, -8.52, 6.17, + -0.80, 2.92, -2.21, -6.80, -2.22, 10.16, + -1.63, -10.11, 6.88, -4.19, -8.53, -2.68, + -7.49, 5.70, 1.23, -1.0 + ], +} + +const nextPattern = pattern => { + switch (pattern) { + case "duk": + return "quak" + case "quak": + return "hvac" + default: + return "duk" + } +} + +const updateStonkPrices = () => { + const today = daysSinceEpoch() + if (stonkMarket.lastDay === today) { + return // already updated + } + + // TODO: Gotta take into account wrapping around to the end of the year + Object.entries(stonkMarket.stonks).forEach(([, stonk]) => { + console.log(stonk.pattern) + console.log('try set') + for (let i = stonkMarket.lastDay; i < today; i++) { + console.log('set lastPrice') + stonk.lastPrice = stonk.price + stonk.price *= 1 + (stonkPatterns[stonk.pattern][stonk.index] / 100) + stonk.index++ + if (stonk.index >= stonkPatterns[stonk.pattern].length) { + stonk.index = 0 + stonk.pattern = nextPattern(stonk.pattern) + } + } + }) + stonkMarket.lastDay = today + //saveGame(true) +} + +const buyStonks = (user, stonkName, quantityPhrase) => { + const quantity = parseAll(quantityPhrase, Math.floor(user.coins / stonkMarket.stonks[stonkName].price)) + if (!quantity) { + return 'Quantity must be positive integer!' + } + const cost = stonkMarket.stonks[stonkName].price * quantity + if (cost > user.coins) { + return `Buying ${commas(quantity)} of ${stonkName.toUpperCase()} would cost ${commas(cost)} HVAC. You only have ${commas(user.coins)}!` + } + user.coins -= cost + user.holdings ??= {} + user.holdings[stonkName] ??= 0 + user.holdings[stonkName] += quantity + //saveGame() + return `Successfully bought ${commas(quantity)} ${stonkName.toUpperCase()}, for a total of ${commas(cost)} HVAC!` +} + +const sellStonks = (user, stonkName, quantityPhrase) => { + user.holdings ??= {} + user.holdings[stonkName] ??= 0 + const quantity = parseAll(quantityPhrase, user.holdings[stonkName]) + if (!quantity) { + return 'Quantity must be positive integer!' + } + if (quantity > user.holdings[stonkName]) { + return `You're trying to sell ${commas(quantity)} ${stonkName.toUpperCase()}, but you only have ${user.holdings[stonkName]}!` + } + const sellValue = stonkMarket.stonks[stonkName].price * quantity + user.holdings[stonkName] -= quantity + user.coins += sellValue + //saveGame() + return `You successfully sold ${commas(quantity)} ${stonkName.toUpperCase()}, for a total of ${commas(sellValue)} HVAC!` +} + +const stonkHelp = + 'Play the stonk market. Prices change every day!\n' + + '`!stonk buy `\n' + + '`!stonk sell `' +command( + ['!stonks', '!stonk', '!st'], + stonkHelp, + async ({ user, args, say }) => { + updateStonkPrices() + let msg = `Market values:\n` + Object.entries(stonkMarket.stonks).forEach(([name, stonk]) => { + const diff = stonk.price - stonk.lastPrice + const diffPercent = diff / stonk.lastPrice + const diffSign = diff > 0 ? '+' : '' + msg += `\`${name.toUpperCase()}\` ${commas(stonk.price)} (${diffSign}${diffPercent.toPrecision(2)}%)\n` + }) + let [action, stonkName, ...quantityPhrase] = args + const noHoldingsMessage = msg + `\nYou don't have any holdings right now.` + if (!action) { + if (!user.holdings) { + return say(noHoldingsMessage) + } + msg += `\nYou have:` + let hasHoldings = false + Object.entries(user.holdings).forEach(([name, holdings]) => { + if (holdings > 0) { + hasHoldings = true + msg += `\n${commas(holdings)} ${name.toUpperCase()} - ${commas(stonkMarket.stonks[name].price * holdings)} HVAC` + } + }) + if (!hasHoldings) { + return say(noHoldingsMessage) + } + return say(msg) + } + + action = action.toLowerCase() + stonkName = stonkName.toLowerCase() + quantityPhrase = quantityPhrase?.join(' ') || '1' + if (action === 'buy' || action === 'b') { + return say(buyStonks(user, stonkName, quantityPhrase)) + } else if (action === 'sell' || action === 's') { + return say(sellStonks(user, stonkName, quantityPhrase)) + } else { + return say(stonkHelp) + } + } +) + +command( + ['!speak'], + '!speak <64-character message>', + async ({ event, say, user }) => { + const today = daysSinceEpoch() + if (user.lastSpeech === today) { + return say(`Sorry, one speech per day, kid.`) + } + if (event.text.length > 64 + '!speak '.length) { + return say(`That message is too long! You get 64 characters, but you gave me ${event.text.length - '!speak'.length}!`) + } + user.lastSpeech = today + const message = event.text.substring(7) + await slack.postToTechThermostatChannel(message) + //saveGame(true) + }, { + hidden: true, + condition: ({ user }) => userHasCheckedQuackgrade(user, 'theVoice') + }) + command( ['!message', '!msg', '!m'], '!message player_id message', - async ({ event, words, say }) => { - const targetId = idFromWord(words[1]) + async ({ event, args, say }) => { + const targetId = idFromWord(args[0]) if (!targetId) { return say('Please specify a valid @ target!') } @@ -1384,4 +1868,32 @@ command( await slack.messageIn(targetId, msg) }, adminOnly) -// webapi.launch() +command( + ['!userjson'], + 'Fetch the raw JSON data for your user', + async ({ user, trueSay }) => trueSay(JSON.stringify(user)), + { hidden: true } +) + +command( + ['!whohas'], + '!whohas ', + async ({ user, say, args }) => { + const achName = args.join(' ') + const matcher = fuzzyMatcher(achName) + const achievement = Object.entries(achievements) + .find(([name, ach]) => matcher.test(ach.name) || matcher.test(name)) + if (!achievement || !user.achievements[achievement[0]]) { + return say(`You don't have any achievement matching '${achName}'`) + } + const [achId, ach] = achievement + const owners = Object.entries(users) + .map(([, potentialOwner]) => potentialOwner) + .filter(potentialOwner => potentialOwner.achievements[achId]) + .map(owner => owner.name) + return say(`${owners.length} ${owners.length === 1 ? 'user has' : 'users have'} _${ach.name}_ :${ach.emoji}:\n${owners.join('\n')}`) + }, + { hidden: true } +) + +webapi.launch() diff --git a/src/games/hvacoins/lore.js b/src/games/hvacoins/lore.js index c41b74c..2133bb7 100644 --- a/src/games/hvacoins/lore.js +++ b/src/games/hvacoins/lore.js @@ -39,6 +39,8 @@ const lore = [ l(`And the ninth...`), l(`Well, the ninth might actually amount to something.`), l(`https://i.imgur.com/eFreg7Y.gif\n`), + + //l(`As you might imagine, the ninth egg was I, the almighty Hvacker.`) ] slack.onReaction(async ({ event, say }) => { @@ -56,11 +58,11 @@ slack.onReaction(async ({ event, say }) => { } return } - console.log(lore[user.lore]) + console.log('lore:', lore[user.lore]) await say(lore[user.lore].correctResponse) user.lore += 1 saveGame() - } catch (e) {console.error(e)} + } catch (e) {console.error('onReaction error', e)} }) const encodeLore = loreNumber => lore[loreNumber].text.startsWith(':') && lore[loreNumber].text.endsWith(':') ? '' : @@ -77,35 +79,35 @@ const loreMessage = (user, say) => { return `Sorry. I'd love to tell you more, but I'm tired. Please check back later.` } -const loreRoute = async ({ say, words, user, isAdmin }) => { +const loreRoute = async ({ say, args, user, isAdmin }) => { user.lore ??= 0 - if (!words[1]) { + if (!args[0]) { const message = loreMessage(user, say) await say(message) if (!lore[user.lore]?.correctReactions) { user.lore += 1 } - saveGame() + //saveGame() console.log('Sent ' + user.name + ':\n' + message) return } - if (words[1] === 'reset') { + if (args[0] === 'reset') { user.lore = 0 - saveGame() + //saveGame() return say(`I have reset your place in the story.`) } if (isAdmin) { - if (words[1] === 'all') { + if (args[0] === 'all') { let loreMessage = '' for (let i = 0; i < user.lore; i++) { loreMessage += lore[i].text + (lore[i].correctResponse || '') + '\n' } return say(loreMessage) } - const jumpTo = parseInt(words[1]) + const jumpTo = parseInt(args[0]) if (!isNaN(jumpTo)) { user.lore = jumpTo - saveGame() + //saveGame() } } } diff --git a/src/games/hvacoins/prestige.js b/src/games/hvacoins/prestige.js index 2656279..5fd86ce 100644 --- a/src/games/hvacoins/prestige.js +++ b/src/games/hvacoins/prestige.js @@ -1,4 +1,4 @@ -const { commas, saveGame, quackGradeMultiplier, prestigeMultiplier, makeBackup } = require('./utils') +const { commas, quackGradeMultiplier, prestigeMultiplier, makeBackup, userHasCheckedQuackgrade } = require('./utils') const { quackStore } = require('./quackstore') const possiblePrestige = coins => { @@ -17,10 +17,10 @@ const totalCostForPrestige = prestigeLevel => { return (tpcRecMemo[prestigeLevel]) || (tpcRecMemo[prestigeLevel] = 1_000_000_000_000 * Math.pow(prestigeLevel, 3) + totalCostForPrestige(prestigeLevel - 1)) } -const prestigeRoute = async ({ say, words, user }) => { +const prestigeRoute = async ({ say, args, user }) => { const possible = possiblePrestige(user.coinsAllTime) const current = user.prestige ??= 0 - if (words[1] === 'me') { + if (args[0] === 'me') { await say( 'This will permanently remove all of your items, upgrades, and coins!\n\n' + 'Say \'!!prestige me\' to confirm.' @@ -53,18 +53,18 @@ const prestigeConfirmRoute = async ({ event, say, user }) => { user.quacks += (possible - user.prestige) user.prestige = possible + user.highestEver = 0 user.coins = 0 - user.items = {} + user.items = {}; + const starterUpgrades = (user.quackUpgrades?.starter || []) + starterUpgrades.forEach(upgradeName => quackStore[upgradeName].effect(user)) user.upgrades = {} - saveGame() await say('You prestiged! Check out !quackstore to see what you can buy!') } const quackStoreListing = (showCost = true) => ([name, upgrade]) => - showCost - ? `:${upgrade.emoji}: *${name}* - Costs *${upgrade.cost} Quack.*\n\n_${upgrade.description}_` - : `:${upgrade.emoji}: *${name}* - Worth *${upgrade.cost} Quack.*\n\n_${upgrade.description}_` + `:${upgrade.emoji}: *${name}* - ${showCost ? 'Costs' : 'Worth'} *${upgrade.cost} Quack.*\n\n_${upgrade.description}_` const allUserQuackUpgrades = user => Object.entries(user.quackUpgrades || {}) @@ -75,6 +75,7 @@ const hasPreReqs = user => ([name, upgrade]) => { return true } const allUserUpgrades = allUserQuackUpgrades(user) + console.log('allUserUpgrades', allUserUpgrades) return upgrade.preReqs.every(preReq => allUserUpgrades.includes(preReq)) } @@ -92,27 +93,31 @@ const quackStoreText = user => `\n\nYou have ${user.quacks ??= 0} quacks to spend.` + `\nQuackStore upgrades are currently boosting your CPS by ${commas((quackGradeMultiplier(user) - 1) * 100)}%` -const quackStoreRoute = async ({ user, say, words }) => { +const quackStoreRoute = async ({ user, say, args }) => { user.quackUpgrades ??= {} const quacks = user.quacks ??= 0 - if (!words[1]) { + if (!args[0]) { await say(quackStoreText(user)) return } - console.log(`Trying to buy ${words[1]}`) - const quackItem = quackStore[words[1]] - if (!quackItem || !unownedQuackItems(user).find(([name]) => name === words[1])) { - await say(`'${words[1]}' is not available in the quack store!`) + console.log(`Trying to buy ${args[0]}`) + const quackItem = quackStore[args[0]] + if (!quackItem || !unownedQuackItems(user).find(([name]) => name === args[0])) { + await say(`'${args[0]}' is not available in the quack store!`) return } if (quackItem.cost > quacks) { - await say(`${words[1]} costs ${quackItem.cost} Quacks, but you only have ${quacks}!`) + await say(`${args[0]} costs ${quackItem.cost} Quacks, but you only have ${quacks}!`) return } user.quacks -= quackItem.cost user.quackUpgrades[quackItem.type] ??= [] - user.quackUpgrades[quackItem.type].push(words[1]) - saveGame() + user.quackUpgrades[quackItem.type].push(args[0]) + if (quackItem.type === 'starter') { + quackItem.effect(user) + } + await say(`You bought ${args[0]}!`) + //saveGame() } const ownedQuacksText = user => diff --git a/src/games/hvacoins/quackstore.js b/src/games/hvacoins/quackstore.js index 07a7488..0bf7619 100644 --- a/src/games/hvacoins/quackstore.js +++ b/src/games/hvacoins/quackstore.js @@ -31,9 +31,6 @@ const quackStore = { //+ '_\n_Averages a 26% CPS boost.', preReqs: ['nuclearFuel'], effect: (cps, user) => { - if (user.name !== 'Sage') { - console.log('Chaos Multiplier', getChaos(Math.round(user.interactions / 50))) - } return cps * getChaos(Math.round(user.interactions / 50)) }, cost: 10 @@ -44,10 +41,67 @@ const quackStore = { type: 'lightning', emoji: 'rose', description: 'Smells nice. Makes lightning twice as likely to strike.', - //+ '_\n_Averages a 26% CPS boost.', effect: lightningOdds => lightningOdds * 2, preReqs: ['nuclearFuel'], cost: 10 + }, + + // Checked Upgrades. Have no effect(), but their existence is referred to elsewhere. + theGift: { + name: 'The Gift', + type: 'checked', + emoji: 'eye-in-speech-bubble', + description: 'Become forewarned of certain events...', + preReqs: ['dryerSheet', 'chaos'], + cost: 10 + }, + + theVoice: { + name: 'The Voice', + type: 'checked', + emoji: 'loud_sound', + description: 'Unlocks the !speak command', + preReqs: ['dryerSheet', 'chaos'], + cost: 50 + }, + + cheeseBaby: { + name: 'cheeseBaby', + type: 'starter', + emoji: 'baby_symbol', + description: 'Start each prestige with 5 mice', + preReqs: ['dryerSheet', 'chaos'], + effect: user => { + user.items.mouse ??= 0 + user.items.mouse += 5 + }, + cost: 5 + }, + + silverSpoon: { + name: 'Silver Spoon', + type: 'starter', + emoji: 'spoon', + description: 'Start each prestige with 5 accountants', + preReqs: ['cheeseBaby'], + effect: user => { + user.items.accountant ??= 0 + user.items.accountant += 5 + }, + cost: 10 + }, + + oceanMan: { + name: 'Ocean Man', + type: 'starter', + emoji: 'ocean', + description: 'Start each prestige with 5 whales', + preReqs: ['silverSpoon'], + effect: user => { + user.items.whale ??= 0 + user.items.whale += 5 + }, + cost: 20 } } diff --git a/src/games/hvacoins/settings.js b/src/games/hvacoins/settings.js index c2d3207..c2cecae 100644 --- a/src/games/hvacoins/settings.js +++ b/src/games/hvacoins/settings.js @@ -1,3 +1,4 @@ module.exports = { - horrorEnabled: false + horrorEnabled: false, + admins: ['Sage'] } \ No newline at end of file diff --git a/src/games/hvacoins/upgrades.js b/src/games/hvacoins/upgrades.js index 7530a7b..82e6e26 100644 --- a/src/games/hvacoins/upgrades.js +++ b/src/games/hvacoins/upgrades.js @@ -14,20 +14,22 @@ const evil = ({ type, description, cost }) => basic({ extraCondition: (user, squadGrades) => squadGrades?.includes('discardHumanMorals'), }) -const heavenly = ({ type, description, cost }) => basic({ +const heavenly = ({ type, description, cost, multiplier = 2 }) => ({ type, description, - count: 60, + condition: (user, squadGrades) => user.items[type] >= 60 && squadGrades?.includes('redemption'), cost, - extraCondition: (user, squadGrades) => squadGrades?.includes('redemption'), + effect: cps => cps * multiplier }) +const disabled = () => false + const baby = ({ type, description, cost }) => basic({ type, description, - count: 80, + count: 70, cost, - extraCondition: (user, squadGrades) => squadGrades?.includes('redemption'), + extraCondition: disabled }) const geometry = ({ type, description, cost }) => basic({ @@ -35,7 +37,7 @@ const geometry = ({ type, description, cost }) => basic({ description, count: 100, cost, - extraCondition: (user, squadGrades) => squadGrades?.includes('redemption'), + extraCondition: disabled }) const universitality = ({ type, description, cost }) => basic({ @@ -43,7 +45,7 @@ const universitality = ({ type, description, cost }) => basic({ description, count: 100, cost, - extraCondition: (user, squadGrades) => squadGrades?.includes('redemption'), + extraCondition: disabled }) module.exports = { @@ -72,8 +74,14 @@ module.exports = { }), hoodedMice: heavenly({ type: 'mouse', - description: 'These monks have nearly reached enlightenment.', + description: 'These monks have nearly reached enlightenment. 10x Mouse CPS.', cost: 1_000_000, + multiplier: 10, + }), + babyMouse: baby({ + type: 'mouse', + description: 'Squeak!', + cost: 6_000_000, }), fasterComputers: basic({ @@ -101,8 +109,14 @@ module.exports = { }), charityFund: heavenly({ type: 'accountant', - description: 'THIS one is more than just a tax break.', + description: 'THIS one is more than just a tax break. 9x Accountant CPS.', cost: 16_333_333, + multiplier: 9, + }), + mathBaby: baby({ + type: 'accountant', + description: '2 + 2 = WAAH!', + cost: 99_999_999, }), biggerBlowhole: basic({ @@ -130,8 +144,14 @@ module.exports = { }), whaleChoir: heavenly({ type: 'whale', - description: `Their cleansing songs reverberate through the sea.`, - cost: 144_000_000 + description: `Their cleansing songs reverberate through the sea. 8x Whale CPS.`, + cost: 144_000_000, + multiplier: 8, + }), + smolWhales: baby({ + type: 'whale', + description: ``, + cost: 8_400_000_000 }), greasyTracks: basic({ @@ -159,7 +179,8 @@ module.exports = { }), toyTrain: heavenly({ type: 'train', - description: 'Something simple. Toot toot!', + description: 'Toot toot! 8x Train CPS.', + multiplier: 8, cost: 2_220_000_000 }), @@ -171,7 +192,7 @@ module.exports = { }), extremelyDryFuel: basic({ type: 'fire', - description: 'Use the ignite command for a secret achievement.', + description: 'Hey, psst, hey. Use the ignite command for a secret achievement.', count: 10, cost: 163_000_000 }), @@ -188,9 +209,15 @@ module.exports = { }), blueFire: heavenly({ type: 'fire', - description: `You can hear it singing with delight.`, + description: `You can hear it singing with delight. 7x Fire CPS.`, + multiplier: 7, cost: 25_200_000_000 }), + cuteFire: baby({ + type: 'fire', + description: `I just met my perfect match...`, + cost: 150_000_000_000 + }), spoonerang: basic({ type: 'boomerang', @@ -217,7 +244,8 @@ module.exports = { }), youRang: heavenly({ type: 'boomerang', - description: 'Your arms and legs recede into your body. You bend at the middle. You fly. And for a moment, you are free.', + description: 'Your arms and legs recede into your body. You bend at the middle. You fly. And for a moment, you are free._\n_7x Boomerang CPS.', + multiplier: 7, cost: 360_000_000_000 }), @@ -246,7 +274,8 @@ module.exports = { }), newMoon: heavenly({ type: 'moon', - description: `Build a second moon to provide space for affordable housing.`, + description: `Build a second moon to provide space for affordable housing. 6x Moon CPS.`, + multiplier: 6, cost: 5_190_000_000_000 }), @@ -275,7 +304,8 @@ module.exports = { }), quietingNectar: heavenly({ type: 'butterfly', - description: 'Calming and extra sweet. Soothes even human ails.', + description: 'Calming and extra sweet. Soothes even human ails. 6x Butterfly CPS.', + multiplier: 6, cost: 75_300_000_000_000 }), @@ -304,7 +334,8 @@ module.exports = { }), funHouseMirror: heavenly({ type: 'mirror', - description: `yoU LOok so siLLY IN thesE THINgs`, + description: `yoU LOok so siLLY IN thesE THINgs. 5X mIRror CpS.`, + multiplier: 5, cost: 1_330_000_000_000_000 }), @@ -333,7 +364,8 @@ module.exports = { }), hannahMontanaLinux: heavenly({ type: 'quade', - description: `The patrician's choice.`, + description: `The patrician's choice. 4x Quade CPS.`, + multiplier: 4, cost: 18_000_000_000_000_000 }), @@ -362,7 +394,8 @@ module.exports = { }), mutualUnderstanding: heavenly({ type: 'hvacker', - description: `lol fat chance, dummy. Points for trying, though`, + description: `lol fat chance, dummy. Points for trying, though. 3x Hvacker CPS`, + multiplier: 3, cost: 250_000_000_000_000_000 }), @@ -391,7 +424,8 @@ module.exports = { }), goVegan: heavenly({ type: 'creator', - description: `Unlock your vegan powers.`, + description: `Unlock your vegan powers. 3x Creator CPS.`, + multiplier: 3, cost: 3_600_000_000_000_000_000 }), @@ -420,7 +454,8 @@ module.exports = { }), coop: heavenly({ type: 'smallBusiness', - description: `By the people, for the people.`, + description: `By the people, for the people. 2x smallBusiness CPS`, + multiplier: 2, cost: 5_140_000_000_000_000_000 }), @@ -449,7 +484,8 @@ module.exports = { }), makePublic: heavenly({ type: 'bigBusiness', - description: `Downplay immediate profit for more long-term benefits.`, + description: `Downplay immediate profit for more long-term benefits. 2x bigBusiness CPS.`, + multiplier: 2, cost: 42_000_000_000_000_000_000 }), diff --git a/src/games/hvacoins/utils.js b/src/games/hvacoins/utils.js index aba01fb..e97b9b3 100644 --- a/src/games/hvacoins/utils.js +++ b/src/games/hvacoins/utils.js @@ -7,7 +7,7 @@ const { quackStore, getChaos } = require('./quackstore') const saveFile = 'hvacoins.json' -const logError = msg => msg ? console.error(msg) : () => { /* Don't log empty message */ } +const logError = msg => msg ? console.error('logError: ', msg) : () => { /* Don't log empty message */ } const loadGame = () => { const game = parseOr(fs.readFileSync('./' + saveFile, 'utf-8'), @@ -53,12 +53,15 @@ const makeBackup = () => { } let saves = 0 -const saveGame = () => { +const saveGame = (force = true) => { if (saves % 100 === 0) { makeBackup() } saves += 1 - fs.writeFileSync('./' + saveFile, JSON.stringify(game, null, 2)) + if (force || saves % 10 === 0) { + console.log('SAVING GAME') + fs.writeFileSync('./' + saveFile, JSON.stringify(game, null, 2)) + } } const maybeNews = say => { @@ -74,9 +77,10 @@ const maybeNews = say => { const idFromWord = word => { if (!word?.startsWith('<@') || !word.endsWith('>')) { - return null + return getIdFromName(word) + } else { + return word.substring(2, word.length - 1) } - return word.substring(2, word.length - 1) } const getSeconds = () => new Date().getTime() / 1000 @@ -113,7 +117,7 @@ const parseAll = (str, allNum) => { return NaN } - str = str.toLowerCase()?.replace(/,/g, '') + str = str?.toLowerCase()?.replace(/,/g, '') || '1' switch (str) { case 'all': @@ -143,12 +147,10 @@ const parseAll = (str, allNum) => { console.log('STR', str) if (str.match(/^\d+$/)) { - console.log('parseInt()') return parseInt(str) } if (str.match(/^\d+\.\d+$/)) { - console.log('parseFloat()') return Math.round(parseFloat(str)) } @@ -185,6 +187,18 @@ const addAchievement = (user, achievementName, say) => { }, 500) } +const fuzzyMatcher = string => new RegExp((string?.toLowerCase() || '').split('').join('.*'), 'i') + +let knownUsers = {} +const getIdFromName = name => { + const matcher = fuzzyMatcher(name?.toLowerCase()) + const found = Object.entries(knownUsers).find(([id, knownName]) => matcher.test(knownName?.toLowerCase())) + if (found) { + return found[0] + } + return null; +} + const getUser = userId => { if (!users[userId]) { users[userId] = { @@ -205,20 +219,24 @@ const getUser = userId => { return users[userId] } +const addCoins = (user, add) => { + user.coins += add + user.coinsAllTime += add + user.coinsAllTime = Math.floor(user.coinsAllTime) + user.coins = Math.floor(user.coins) +} + const getCoins = userId => { const user = getUser(userId) const currentTime = getSeconds() const lastCheck = user.lastCheck || currentTime const secondsPassed = currentTime - lastCheck - const increase = getCPS(user) * secondsPassed - user.coins += increase - user.coinsAllTime += increase - user.coins = Math.floor(user.coins) + addCoins(user, getCPS(user) * secondsPassed) user.lastCheck = currentTime setHighestCoins(userId) - saveGame() + //saveGame() return user.coins } @@ -290,7 +308,8 @@ const singleItemCps = (user, itemName) => { const itemUpgradeCps = itemUpgrades.reduce((totalCps, upgrade) => upgrade.effect(totalCps, user), 1) // console.log('itemUpgradeCps', itemUpgradeCps) - const userGeneralUpgrades = user.upgrades.general || [] + user.upgrades.general ??= [] + const userGeneralUpgrades = user.upgrades.general const generalUpgradeCps = Object.entries(userGeneralUpgrades).reduce((total, [, upgradeName]) => upgrades[upgradeName].effect(total, user), 1) // console.log('generalUpgradeCps', generalUpgradeCps) @@ -382,6 +401,41 @@ const addReactions = async ({ app, channelId, timestamp, reactions }) => { } } } + +const daysSinceEpoch = () => { + const today = new Date().getTime() + const epoch = new Date(0).getTime() + return Math.floor((today - epoch) / (1000 * 60 * 60 * 24)) +} + +const dayOfYear = () => { + const date = new Date() + return ((Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) - Date.UTC(date.getFullYear(), 0, 0)) / 24 / 60 / 60 / 1000) +} + +game.stonkMarket ??= { + lastDay: daysSinceEpoch(), + stonks: { + duk: { + pattern: "duk", + index: 0, + price: 1_410_911_983_728 + }, + quak: { + pattern: "quak", + index: 0, + price: 5_111_242_778_696 + }, + honk: { + pattern: "honk", + index: 0, + price: 511_915_144_009 + }, + } +} + +const userHasCheckedQuackgrade = (user, quackGrade) => (user.quackUpgrades?.checked || []).includes(quackGrade) + module.exports = { saveGame, makeBackup, @@ -408,5 +462,11 @@ module.exports = { chaosFilter, addReactions, getCompletedSquadgradeNames, - game + game, + dayOfYear, + daysSinceEpoch, + userHasCheckedQuackgrade, + fuzzyMatcher, + addCoins, + setKnownUsers: users => knownUsers = users } diff --git a/src/games/hvacoins/webapi.js b/src/games/hvacoins/webapi.js index ff899d0..8e0c863 100644 --- a/src/games/hvacoins/webapi.js +++ b/src/games/hvacoins/webapi.js @@ -4,10 +4,10 @@ const port = 3001 const crypto = require('crypto') const base64 = require('base-64') const slack = require('../../slack') -const { game: { users } } = require('./utils') +const { game: { users }, getUser, fuzzyMatcher } = require('./utils') const apiGetUserId = hash => { - return Object.entries(userGetter.users) + return Object.entries(users) .filter(([id, user]) => user.pwHash === hash) .map(([id, user]) => id)[0] } @@ -17,12 +17,21 @@ const makeHash = pw => .update(pw) .digest('hex') +const illegalCommands = ['!', '!b'] +const lastCalls = {} const addCommand = ({ commandNames, helpText, action, condition, hidden }) => { + if (illegalCommands.find(command => commandNames.includes(command))) { + commandNames.forEach(name => + app.get('/' + name.replace(/!/gi, ''), async (req, res) => res.send('Command is illegal over the web api.')) + ) + return + } const route = async (req, res) => { - const say = async msg => res.send(msg) + const say = async msg => res.send(msg + '\n') try { const words = ['', ...Object.keys(req.query)] - console.log('INCOMING API CALL:', name, words) + const [commandName, ...args] = words + console.log('INCOMING API CALL:', commandName, words) const encoded = req.header('Authorization').substring(5) const decoded = base64.decode(encoded).substring(1) const event = { @@ -37,8 +46,8 @@ const addCommand = ({ commandNames, helpText, action, condition, hidden }) => { console.log(' bad password') return } - const lastCall = userGetter.users[event.user].lastApiCall || 0 - const secondsBetweenCalls = 5 + const lastCall = lastCalls[event.user] || 0 + const secondsBetweenCalls = 30 const currentTime = Math.floor(new Date().getTime() / 1000) if (lastCall + secondsBetweenCalls > currentTime) { res.status(400) @@ -47,12 +56,21 @@ const addCommand = ({ commandNames, helpText, action, condition, hidden }) => { return } console.log(` went through for ${slack.users[event.user]}`) - userGetter.users[event.user].lastApiCall = currentTime + lastCalls[event.user] = currentTime - await action({ event, say, words }) + const user = getUser(event.user) + const haunted = false + //await action({ event, say, words, args, commandName }) + const canUse = await condition({ event, say, words, commandName, args, user, userId: event.user, isAdmin: event.user.includes(slack.users.Sage) }) + if (!canUse) { + await say(`Command '${words[0]}' not found`) + return + } + await action({ event, say, trueSay: say, words, args, commandName, user, userId: event.user, haunted }) } catch (e) { - console.error(e) - await say(e.stack) + console.error('route error', e) + await say(`Routing error. Make sure you've set up API access with the !setpw command in slack!\n` + + 'Then you can use calls like `curl -u ":yourpw" \'http://10.3.0.48:3001/stonks\'`') } } commandNames.forEach(name => diff --git a/src/games/trivia.js b/src/games/trivia.js index fc964a5..8da3e2a 100644 --- a/src/games/trivia.js +++ b/src/games/trivia.js @@ -6,7 +6,7 @@ const getTrivia = async () => axios.get('https://opentdb.com/api.php?amount=10&c } }) .then(res => res.data.results) - .catch(console.error) + .catch(e => console.error('trivia error', e)) module.exports = { getTrivia diff --git a/src/slack/index.js b/src/slack/index.js index ec19cf8..0e44b14 100644 --- a/src/slack/index.js +++ b/src/slack/index.js @@ -1,6 +1,6 @@ const { App: SlackApp } = require('@slack/bolt') const config = require('../config') -const { addReactions } = require('../games/hvacoins/utils') +const { addReactions, saveGame } = require('../games/hvacoins/utils') const temperatureChannelId = 'C034156CE03' @@ -27,7 +27,7 @@ try { console.log('Failed to initialize SlackApp', e) } -const pollTriggers = ['!temp', '!temperature', '!imhot', '!imcold'] +const pollTriggers = ['!temp', '!temperature', '!imhot', '!imcold', '!imfreezing', '!idonthavemysweater'] const halfTriggers = ['change temperature', "i'm cold", "i'm hot", 'quack', 'hvacker', '<@U0344TFA7HQ>'] const sendHelp = async (say, prefix) => { @@ -53,6 +53,7 @@ const getMessage = async ({ channel, ts }) => app.client.conversations.history({ }) app.event('reaction_added', async ({ event, context, client, say }) => { + console.log('reaction_added', event) for (const listener of reactionListeners) { listener({ event, say }) } @@ -70,6 +71,8 @@ const users = { U0X0ZQCN6: 'Caleb', U03BBTD4CQZ: 'Fernando', U03DF152WUV: 'Nik', + U2X0SG7BP: 'John', + UR2H5KNHY: 'Jake', Sage: 'U028BMEBWBV', Adam: 'U02U15RFK4Y', @@ -81,15 +84,34 @@ const users = { Caleb: 'U0X0ZQCN6', Hvacker: 'U0344TFA7HQ', Fernando: 'U03BBTD4CQZ', - Nik: 'U03DF152WUV' + John: 'U2X0SG7BP', + Jake: 'UR2H5KNHY', } +const buildSayPrepend = ({ say, prepend }) => async msg => { + if (typeof(msg) === 'string') { + return say(prepend + msg) + } + return say({ + ...msg, + text: prepend + msg.text + }) +} + +process.once('SIGINT', code => { + saveGame(true) + process.exit() +}) + const activePolls = {} const testId = 'U028BMEBWBV_TEST' let testMode = false app.event('message', async ({ event, context, client, say }) => { - if (event.subtype !== 'message_changed') { - console.log(event) + if (event.subtype !== 'message_changed' && event?.text !== '!') { + console.log('message.event', { + ...event, + userName: users[event.user] + }) } if (event?.user === users.Sage) { if (event?.text.startsWith('!')) { @@ -116,6 +138,10 @@ app.event('message', async ({ event, context, client, say }) => { } if (event.user === users.Sage && event.channel === 'D0347Q4H9FE') { if (event.text === '!!kill') { + saveGame(true) + process.exit(1) + } else if (event.text === '!!restart') { + saveGame(true) process.exit() } if (event.text?.startsWith('!say ') || event.text?.startsWith('!say\n')) { @@ -167,6 +193,7 @@ app.event('message', async ({ event, context, client, say }) => { reactCounts[name] += 1 } }) + console.log('REACT COUNTS', JSON.stringify(reactCounts)) const contentVotes = reactCounts[goodEmoji] || 0 let hotterVotes = reactCounts[hotterEmoji] || 0 @@ -188,11 +215,11 @@ app.event('message', async ({ event, context, client, say }) => { let text if (hotterVotes > colderVotes && hotterVotes > contentVotes) { - text = `<@${users.Quade}> The people have spoken, and would like to ` + text = `<@${users.Adam}> The people have spoken, and would like to ` text += 'raise the temperature, quack.' requestTempChange('Hotter') } else if (colderVotes > hotterVotes && colderVotes > contentVotes) { - text = `<@${users.Quade}> The people have spoken, and would like to ` + text = `<@${users.Adam}> The people have spoken, and would like to ` text += 'lower the temperature, quack quack.' requestTempChange('Colder') } else { @@ -233,7 +260,7 @@ const messageIn = async (channel, optionsOrText) => { const startPoll = async () => { const sent = await postToTechThermostatChannel({ - text: ` Temperature poll requested! In ${pollingMinutes} minutes the temperature will be adjusted.\n` + + text: ` Temperature poll requested! In ${pollingMinutes} minutes the temperature will be adjusted.\n` + `Pick :${colderEmoji}: if you want it colder, :${hotterEmoji}: if you want it hotter, or :${goodEmoji}: if you like it how it is.` + '\n(Note that I can\'t actually change the temperature yet. Make Quade do it!)' }) @@ -269,13 +296,13 @@ const decodeData = (key, message) => { const onReaction = listener => reactionListeners.push(listener) +const channelIsIm = async channel => (await app.client.conversations.info({ channel }))?.channel?.is_im + onReaction(async ({ event }) => { - if (event.user === users.Sage) { - if (event.reaction === 'x') { - try { - await app.client.chat.delete({ channel: event.item.channel, ts: event.item.ts }) - } catch (e) { - } + if (event.reaction === 'x' && (event.user === users.Sage || await channelIsIm(event.item.channel))) { + try { + await app.client.chat.delete({ channel: event.item.channel, ts: event.item.ts }) + } catch (e) { } } }) @@ -296,5 +323,6 @@ module.exports = { messageIn, testMode, testId, - users + users, + buildSayPrepend }