Compare commits

...

10 Commits

Author SHA1 Message Date
Sage Vaillancourt 969a6b900b !whohas fix 2023-08-03 11:57:27 -04:00
Sage Vaillancourt fdc811d49b Remove !user-list 2023-08-03 11:55:17 -04:00
Sage Vaillancourt 7915e3803c Add a bit of a README 2023-08-03 11:38:06 -04:00
Sage Vaillancourt dcabca0c1a Remove the pebblisp code feature 2023-08-03 11:13:50 -04:00
Sage Vaillancourt be0b49393f Move a bit more config into hvacoins.json and users.json 2023-08-03 10:49:02 -04:00
Sage Vaillancourt 4433c19d04 Add Governments.
New betting achievement.
Add !pet
Move from Sage to Admin naming.
Add cursed pics for hauntings (and reduce haunting odds)
Add saveGame() reason messages.
Add mining upgrades.
Add semi-live updating leaderboards.
Add thorough emoji validation.
Move user IDs to a separate json file.
More details (like current coin count) in !buy menu.
Limit temperature polls to prevent spam.
Some work on a prestige menu.
Several additional quackgrades.
Change text games to try editing messages live.
Named upgrades.
2023-06-29 10:41:55 -04:00
Sage Vaillancourt ad021cf9a5 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
2022-05-19 11:09:16 -04:00
Sage Vaillancourt 6dabe9d85a Several fixes and additions:
Add Nik.
Consider removing Tyler but don't.
Fix skin-color temp votes.
Fix prestige gating.
Tweak !mine odds.
Pass squadgrades to upgrade condition-checking.
Fix amount parsing in edge cases.
Add changelog.
New quackgrade.
Upgrades re-org.
Add evil/heavenly upgrades dependent on squadgrades.
Add minimum to chaosFilter.
2022-05-03 09:56:10 -04:00
Sage Vaillancourt 4e15721803 New additions
Temp-change votes cancel each other out.
More achievements.
Better !buy layout.
2 new buyableItems.
More advanced command flags.
Centralize user and coin fetching for commands.
Display hvacker's owned soul count.
Rank leaderboard by prestige, then CPS.
Start centralize some settings.

Add:
- Quack Store
- Lore
- Horror mode.
- hidden payloads to messages.
- Lightning strikes.
- Cups game.
- Emojis in non-dm !a calls.
- Human-readable numbers.
- Gamble-loss mockery.
- Chaos.
- Prestige emojis.
2022-04-20 12:05:02 -04:00
Sage Vaillancourt 8577f6f272 More precise ngift error messages. 2022-03-15 11:45:30 -04:00
22 changed files with 9610 additions and 726 deletions

20
README.md Normal file
View File

@ -0,0 +1,20 @@
# Hvacker
A Slack-based idle game inspired by (and/or ripping off) Cookie Clicker.
Endlessly self-referential and full of outdated or otherwise 100% unfunny in-jokes, the game has
actually ended up pretty playable. Progression is classic diminishing-returns idler, and a few
social elements keep things from getting stale too quickly.
It also features (among other things) a simple poll system for voting on thermostat controls (hence
the name, HVACker) and was originally intended to control a thermostat directly, but the system on
hand ended up lacking the appropriate API support.
## Design Philosophy
Unlike most game development frameworks, Slack has significant rate-limiting depending on the
actions you want to take. Thus, it was decided that as few elements as possible should actually
update in real time. Instead of processing game ticks as frequently as possible, they are handled
only at request time. Essentially, using a very large delta value between ticks.
This also leaves the Hvacker server relatively lightweight, in an idle state at nearly all times.

5778
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,9 @@ const url = ''
const headers = new Headers()
headers.append('Authorization', 'Basic ' + base64.encode(config.honeywellKey + ':' + config.honeywellSecret))
fetch(url, {method:'GET',
headers: headers,
//credentials: 'user:passwd'
fetch(url, {
method: 'GET',
headers: headers
// credentials: 'user:passwd'
}).then(response => response.json())
.then(json => console.log(json));
.then(json => console.log('json', json))

View File

@ -1,12 +1,12 @@
const routine = require('./routine')
const emptyBoard = [
[' ', ' ', ' ', ' ', ' ', ' ', ' ',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ',],
[' ', ' ', ' ', ' ', ' ', ' ', ' ',]
[' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' '],
[' ', ' ', ' ', ' ', ' ', ' ', ' ']
]
const textFromBoard = board => {
@ -54,7 +54,7 @@ const checkDiagonals = board => {
board[row][col] === board[row + 1][col + 1] &&
board[row][col] === board[row + 2][col + 2] &&
board[row][col] === board[row + 3][col + 3]
){
) {
return board[row][col]
}
}
@ -65,7 +65,7 @@ const checkDiagonals = board => {
board[row][col] === board[row + 1][col - 1] &&
board[row][col] === board[row + 2][col - 2] &&
board[row][col] === board[row + 3][col - 3]
){
) {
return board[row][col]
}
}
@ -76,7 +76,7 @@ const checkDiagonals = board => {
const checkFull = board => {
for (const row of board) {
for (const col of row) {
if (col !== ' ') {
if (col === ' ') {
return null
}
}

View File

@ -2,12 +2,12 @@ module.exports = {
leaderBoardViewer: {
name: 'Leaderboard-Viewer',
description: 'Thank you for viewing the leaderboard!',
emoji: 'trophy',
emoji: 'trophy'
},
seeTheQuade: {
name: 'See the Quade',
description: 'Quade has appeared in your buyables',
emoji: 'quade',
emoji: 'quade'
},
greenCoin: {
name: 'Lucky Green Coin',
@ -34,6 +34,21 @@ 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'
},
sigmaBets: {
name: 'Make a bet over 100 Quintillion',
description: 'Return to monke',
emoji: 'yeknom'
},
ignited: {
name: 'You light my fire, baby',
description: 'And you pay attention to descriptions!',
@ -45,8 +60,79 @@ 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'
},
government100: {
name: 'Run 100 Governments',
description: `I hope you're using them for something good...`,
emoji: 'japanese_castle'
},
weAllNeedHelp: {
name: `View the '!coin' help`,
name: 'View the \'!coin\' help',
description: 'We all need a little help sometimes',
emoji: 'grey_question'
},
@ -71,9 +157,25 @@ module.exports = {
description: 'You absolutely know how to party.',
emoji: 'sunglasses'
},
itsOverNineHundred: {
name: 'Play the HVAC game 1000 times',
description: 'It\'s over nine hundred and ninety-nine!',
emoji: 'chart_with_upwards_trend'
},
youDisgustMe: {
name: 'You disgust me',
description: 'Like, wow.',
emoji: 'nauseated_face'
},
bookWorm: {
name: 'Take a peek at the lore',
description: 'It\'s 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'
}
}

View File

@ -1,25 +1,17 @@
const buyableItems = require('./buyableItems');
const { commas, saveGame, setHighestCoins, addAchievement, getCoins, getUser, singleItemCps } = require('./utils');
const buyableItems = require('./buyableItems')
const { commas, setHighestCoins, addAchievement, getUser, singleItemCps, chaosFilter, fuzzyMatcher, calculateCost } = require('./utils')
const slack = require('../../slack')
const calculateCost = ({ itemName, user, quantity = 1 }) => {
let currentlyOwned = user.items[itemName] || 0
let realCost = 0
for (let i = 0; i < quantity; i++) {
realCost += Math.ceil(buyableItems[itemName].baseCost * Math.pow(1.15, currentlyOwned || 0))
currentlyOwned += 1
}
return realCost
}
const leaderboardUpdater = {}
const getItemHeader = user => ([itemName, { baseCost, description, emoji }]) => {
const itemCost = commas(user ? calculateCost({ itemName, user }) : baseCost)
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:' +
@ -29,9 +21,24 @@ const buildBlock = ({ user, itemName, cost, cps }) => ({
type: 'section',
text: {
type: 'mrkdwn',
text: `${itemName} :${buyableItems[itemName].emoji}:x${user.items[itemName] || 0} - 𝕳${commas(cost)} - ${commas(cps)} CPS\n_${buyableItems[itemName].description}_`
text: `${itemName} :${buyableItems[itemName].emoji}:x${user.items[itemName] || 0} - H${commas(cost)} - ${commas(cps)} CPS\n_${buyableItems[itemName].description}_`
},
accessory: {
type: 'button',
text: {
type: 'plain_text',
text: '1',
emoji: true
},
value: 'buy_' + itemName,
action_id: 'buy_' + itemName
}
})
const buildBlock2 = ({ user, itemName, cost, cps }) => ({
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
@ -41,72 +48,111 @@ const buildBlock = ({ user, itemName, cost, cps }) => ({
value: 'buy_' + itemName,
action_id: 'buy_' + itemName
},
{
type: 'button',
text: {
type: 'plain_text',
text: 'Buy 1',
emoji: true
},
value: 'buy_' + itemName,
action_id: 'buy_' + itemName
}
]
})
const buyText2 = (highestCoins, user) => {
const buyText2 = (highestCoins, user, extraMessage = '') => {
return ({
text: buyableText(highestCoins, user),
blocks: Object.entries(buyableItems)
.filter(canView(highestCoins))
.map(([itemName, item]) => {
text: (extraMessage && extraMessage + '\n')
+ `You have ${commas(user.coins)} HVAC to spend.\n`
+ buyableText(highestCoins, user),
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: (extraMessage && extraMessage + '\n')
+ `You have ${commas(user.coins)} HVAC to spend.\n`
},
},
...Object.entries(buyableItems)
.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 })
}).map(buildBlock)
]
})
}
const buyRoute = async ({ event, say, words }) => {
const user = getUser(event.user)
const buying = words[1]
const maxQuantity = ({ itemName, user, currentCoins }) => {
let quantity = 1
while (calculateCost({ itemName, user, quantity: quantity + 1 }) <= currentCoins) {
quantity++
}
return quantity
}
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
}
let quantity = 1
const currentCoins = getCoins(event.user)
if (words[2] === 'max') {
while (calculateCost({ itemName: buying, user, quantity: quantity + 1 }) <= currentCoins) {
quantity++
}
const [buyableName, buyableItem] = buyable
let quantity
const currentCoins = user.coins
const max = maxQuantity({ itemName: buyableName, user, currentCoins })
if (!args[1]) {
quantity = 1
} else if (args[1] === 'max') {
quantity = max
} else {
quantity = parseInt(words[2] || '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)}`)
await say(`You don't have enough coins! You need ${commas(realCost)}`)
return
}
user.coins -= realCost
user.items[buying] = user.items[buying] || 0
user.items[buying] += quantity
console.log(buying, user.items.mouse)
if (buying === 'mouse' && user.items.mouse >= 100) {
addAchievement(user, 'ratGod', say)
user.items[buyableName] = user.items[buyableName] || 0
user.items[buyableName] += quantity
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 }) => {
@ -114,19 +160,25 @@ const buyButton = async ({ body, ack, say, payload }) => {
const buying = payload.action_id.substring(4)
console.log(`buyButton ${buying} clicked`)
const event = {
user: body.user.id,
user: body.user.id
}
const user = getUser(event.user)
const words = ['', buying, '1']
await buyRoute({ event, say, words })
const words = ['', buying, body.actions[0].text]
const [commandName, ...args] = words
let extraMessage = ''
say = async text => extraMessage = text
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,
ts: body.message.ts,
...buyText2(highestCoins, user)
...buyText2(highestCoins, user, extraMessage)
})
await leaderboardUpdater.updateAllLeaderboards()
}
Object.keys(buyableItems).forEach(itemName => slack.app.action('buy_' + itemName, buyButton))
module.exports = buyRoute
module.exports = { buyRoute, leaderboardUpdater }

View File

@ -3,72 +3,105 @@ 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: 'train100',
},
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: 'fire100',
},
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: 'moon100',
},
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: 'mirror100',
},
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: 'quade100',
},
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: 'hvacker100',
},
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: 'creator100',
},
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.',
own100Achievement: 'smallBusiness100',
},
bigBusiness: {
baseCost: 26_210_000_000_000_000,
earning: 23_650_000_000,
emoji: 'office',
description: 'The place where the smallBusiness goes to work.',
own100Achievement: 'bigBusiness100',
},
government: {
baseCost: 367_210_000_000_000_000,
earning: 185_000_000_000,
emoji: 'japanese_castle',
description: 'By the people, for the people, etc. etc.',
own100Achievement: 'government100',
},
}

View File

@ -0,0 +1,174 @@
const { game, getUser, saveGame, petBoost, getCoins, updateAll, getCPS, addCoins, commas} = require('./utils')
const slack = require('../../slack')
const maxValue = 10
const makePet = () => ({
food: 0,
fun: 0,
})
const bad = (text = '') => `
_^__^_
/ x x \\${text && ` "${text}"`}
>\\ o /<
----
`
const normal = (text = '') => `
_^__^_
/ o o \\${text && ` "${text}"`}
>\\ __ /<
----
`
const great = (text = '') => `
_^__^_
/ ^ ^ \\${text && ` "${text}"`}
>\\ \\__/ /<
----
`
const makeBar = (name, value) => {
const left = '#'.repeat(value)
const right = ' '.repeat(maxValue - value)
return `${name}:`.padEnd(6) + `[${left}${right}]`
}
const buildBlocks = ({text}) => [
{
type: 'section',
text: {
type: 'mrkdwn',
text
}
},
{
type: 'actions',
elements: [
buildBlock('Feed'),
buildBlock('Play'),
]
}]
const buildBlock = actionName => (
{
type: 'button',
text: {
type: 'plain_text',
text: actionName,
emoji: true
},
value: actionName,
action_id: actionName
}
)
// game.channelPets ??= {}
const petToText = (pet, additional, say) => {
const stats = Object.values(pet)
const hasTerribleStat = stats.filter(value => value < 1).length > 0
const averageStat = stats.reduce((total, current) => total + current, 0) / stats.length
let pic
if (hasTerribleStat && averageStat < 3) {
pic = bad
} else if (!hasTerribleStat && averageStat > 8) {
pic = great
} else {
pic = normal
}
let speech
if (pic === bad || Math.random() < 0.5) {
speech = pic()
} else {
speech = pic('Mrow')
}
additional ??= ''
if (additional) {
additional = `\n${additional}\n`
}
const text =
'```\n'
+ `Current HVAC Multiplier: ${petBoost()}x\n`
+ `${makeBar('Food', pet.food)}`
+ `\n${makeBar('Fun', pet.fun)}`
+ '\n'
+ speech
+ '```'
+ additional
saveGame('pet generation')
const ret = ({
text,
blocks: buildBlocks({text})
})
// If there's a say() this is a new message, otherwise, we're editing.
if (say) {
say(ret).then(({ channel, ts }) => {
// game.channelPets[channel] = ts
return updateAll({ name: 'pet', add: { channel, ts }})
}).catch(console.error)
}
return ret
}
const updateEveryone = async additional =>
updateAll({ name: 'pet', ...petToText(game.pet, additional) })
const statDown = () => {
const pet = (game.pet ??= makePet())
pet.food = Math.max(pet.food - 1, 0)
pet.fun = Math.max(pet.fun - 1, 0)
updateEveryone().catch(console.error)
setTimeout(() => {
Object.entries(game.users).forEach(([id, u]) => u.coins = getCoins(id))
statDown()
}, 1000 * 60 * 90) // Every 90 minutes
}
statDown()
const addInteraction = ({ actionId, perform }) =>
slack.app.action(actionId, async ({ body, ack, say, payload }) => {
try {
await ack()
game.pet ??= makePet()
const [everyone, local] = perform(game.pet, getUser(body.user.id))
await updateEveryone(everyone)
if (local) {
await say(local)
}
} catch (e) {
console.error(e)
}
})
const oneMinuteOfCps = user => Math.floor(getCPS(user) * 60)
addInteraction({ actionId: 'Feed', perform: (pet, user) => {
if (pet.food >= 10) {
return [`I'm too full to eat more, ${user.name}!`]
}
const oneMinute = oneMinuteOfCps(user)
addCoins(user, oneMinute)
pet.food += 1
return [`Thanks for the grub, ${user.name}!`, `Earned ${commas(oneMinute)} HVAC for feeding our pet!`]
} })
addInteraction({ actionId: 'Play', perform: (pet, user) => {
if (pet.fun >= 10) {
return [`I'm too tired for more games, ${user.name}.`]
}
const oneMinute = oneMinuteOfCps(user)
addCoins(user, oneMinute)
pet.fun += 1
return [`Thanks for playing, ${user.name}!`, `Earned ${commas(oneMinute)} HVAC for playing with our pet!`]
}})
module.exports = {
makePet,
petToText
}

File diff suppressed because it is too large Load Diff

113
src/games/hvacoins/lore.js Normal file
View File

@ -0,0 +1,113 @@
const { addAchievement, saveGame, getUser } = require('./utils')
const slack = require('../../slack')
let loreCount = 0
const l = (text, {correctReactions, correctResponse, incorrectResponse, jumpTo} = {}) => {
loreCount += 1
return {
text,
correctReactions,
correctResponse,
incorrectResponse,
jumpTo
}
}
const lore = [
l(`Allow me to tell you a story.`),
l(`Once upon a time, there were two young ducks named Genevieve and Isaiah.`),
l(`Isaiah was known mostly for being a big butthole whomst no one liked.`),
l(`Genevieve was known for a singing voice that could peel the paint off your car.`),
l(`Nevertheless, there's was a passionate love affair.`),
l(`_Honking_`),
l(`...`),
l(`Hey you know, it's rather cold, don't you think? Could you start a fire for us?`, {
correctReactions: ['fire'],
correctResponse: `Thank you.`,
incorrectResponse: `Well, you're looking in the right place, anyway.`,
}),
l(`Anyway, together they laid nine eggs.`),
l(`The first to hatch was Rick, who grew up into a fine bird with beautiful feathers.`),
l(`The second was Claire, who grew into an even finer bird, glowing with Duckish beauty.`),
l(`The third was Marf, who developed a severe addiction to Elmer's glue.`),
l(`Bird four was Yoink, who considered Tupperware a hobby.`),
l(`The fifth was Big Bob.`),
l(`The sixth was Jess, who became the number one checkers player in the world.`),
l(`The seventh was Small Bob.`),
l(`The eighth was Maurice, who had quite a few words to say about the French, and was eventually elected president.`),
l(`And the ninth...`),
l(`Well, the ninth might actually amount to something.`),
l(`https://i.imgur.com/eFreg7Y.gif\n`),
]
slack.onReaction(async ({ event, say }) => {
try {
const user = getUser(event.user)
const item = await slack.getMessage({ channel: event.item.channel, ts: event.item.ts })
const message = item.messages[0].text
const loreData = slack.decodeData('lore', message)
if (!loreData || user.lore !== loreData.index || !loreData.correctReactions) {
return
}
if (!loreData.correctReactions.includes(event.reaction)) {
if (lore[user.lore].incorrectResponse) {
await say(lore[user.lore].incorrectResponse + encodeLore(user.lore))
}
return
}
console.log('lore:', lore[user.lore])
await say(lore[user.lore].correctResponse)
user.lore += 1
saveGame(`updating ${user.name}'s lore counter`)
} catch (e) {console.error('onReaction error', e)}
})
const encodeLore = loreNumber => lore[loreNumber].text.startsWith(':') && lore[loreNumber].text.endsWith(':') ? '' :
slack.encodeData('lore', {
index: loreNumber,
correctReactions: lore[loreNumber].correctReactions
})
const loreMessage = (user, say) => {
if (lore[user.lore]) {
return lore[user.lore].text + encodeLore(user.lore)
}
addAchievement(user, 'bookWorm', say)
return `Sorry. I'd love to tell you more, but I'm tired. Please check back later.`
}
const loreRoute = async ({ say, args, user, isAdmin }) => {
user.lore ??= 0
if (!args[0]) {
const message = loreMessage(user, say)
await say(message)
if (!lore[user.lore]?.correctReactions) {
user.lore += 1
}
//saveGame()
console.log('Sent ' + user.name + ':\n' + message)
return
}
if (args[0] === 'reset') {
user.lore = 0
//saveGame()
return say(`I have reset your place in the story.`)
}
if (isAdmin) {
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(args[0])
if (!isNaN(jumpTo)) {
user.lore = jumpTo
//saveGame()
}
}
}
module.exports = loreRoute

View File

@ -1,56 +1,54 @@
const { getUser, getCoins, commas, saveGame } = require('./utils');
const quackStore = require('./quackstore');
const { commas, quackGradeMultiplier, prestigeMultiplier, makeBackup, userHasCheckedQuackgrade, getUser } = require('./utils')
const { quackStore } = require('./quackstore')
const buyableItems = require('./buyableItems')
const slack = require('../../slack')
const possiblePrestige = coins => {
let p = 0
while (tpcRec(p + 1) <= coins) {
while (totalCostForPrestige(p + 1) <= coins) {
p += 1
}
return p
}
const totalCostForPrestige = prestigeLevel => {
let cost = 0
while (prestigeLevel) {
cost += 1_000_000_000_000 * Math.pow(prestigeLevel, 3)
prestigeLevel -= 1
}
return cost
}
const tpcRecMemo = []
const tpcRec = prestigeLevel => {
const totalCostForPrestige = prestigeLevel => {
if (prestigeLevel === 0) {
return 0
}
return (tpcRecMemo[prestigeLevel]) || (tpcRecMemo[prestigeLevel] = 1_000_000_000_000 * Math.pow(prestigeLevel, 3) + tpcRec(prestigeLevel - 1))
return (tpcRecMemo[prestigeLevel]) || (tpcRecMemo[prestigeLevel] = 1_000_000_000_000 * Math.pow(prestigeLevel, 3) + totalCostForPrestige(prestigeLevel - 1))
}
// TODO
const prestigeRoute = async ({ event, say, words }) => {
const user = getUser(event.user)
getCoins(event.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.'
)
} else {
const currentCost = totalCostForPrestige(possible)
const nextCost = totalCostForPrestige(possible + 1)
const diff = nextCost - currentCost
const progress = user.coinsAllTime - currentCost
const bars = Math.round((progress / diff) * 10)
const empty = 10 - bars
const progressBar = '[' + '='.repeat(bars) + ' '.repeat(empty) + ']'
await say(
`Current Prestige: ${commas(current)}\n\n` +
`Quacks gained if you prestige now: ${commas(possible - current)}\n\n` +
`HVAC until next quack: ${commas(Math.round(tpcRec(possible + 1) - user.coinsAllTime))}\n\n` +
'Say \'!prestige me\' to start the prestige process.'
`Next quack progress: \`${progressBar} ${commas(diff)} \`\n\n` +
'Say \'!prestige me\' to start the prestige process.' +
`\n\nYour prestige is currently boosting your CPS by ${commas((prestigeMultiplier(user) - 1) * 100)}%`
)
}
}//, true, adminOnly)
}//, true, adminOnly)
// TODO
const prestigeConfirmRoute = async ({ event, say, words }) => {
const user = getUser(event.user)
getCoins(event.user)
const prestigeConfirmRoute = async ({ event, say, user, YEET }) => {
if (YEET) {
return say(prestigeMenu(user))
}
const possible = possiblePrestige(user.coinsAllTime)
const current = user.prestige
if (possible <= current) {
@ -61,65 +59,243 @@ const prestigeConfirmRoute = async ({ event, say, words }) => {
await say('Say exactly \'!!prestige me\' to confirm')
return
}
console.log('possible', possible)
console.log('user.prestige', user.prestige)
await makeBackup()
user.isPrestiging = true
user.quacks ??= 0
user.quacks += (possible - user.prestige)
console.log('user.quacks', user.quacks)
user.prestige = possible
user.highestEver = 0
user.coins = 0
user.items = {}
user.holdings = {}
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 = ([name, upgrade]) =>
`:${upgrade.emoji}: *${name}* - Costs *${upgrade.cost} Quack.*\n\n_${upgrade.description}_`
await say(prestigeMenu(user))
await say(`Say !quack _upgrade-name_ to purchase new quackgrades!`)
//await say('You prestiged! Check out !quackstore to see what you can buy!')
}
const quackStoreListing = (showCost = true) => ([name, upgrade]) =>
`:${upgrade.emoji}: *${name}* - ${showCost ? 'Costs' : 'Worth'} *${upgrade.cost} Quack.*\n\n_${upgrade.description}_`
const allUserQuackUpgrades = user =>
Object.entries(user.quackUpgrades || {})
.map(([type, upgrades]) => upgrades)
.map(([type, upgrades]) => upgrades).flatMap(x => x)
const hasPreReqs = user => ([name, upgrade]) => {
if (!upgrade.preReqs) {
return true
}
const allUserUpgrades = allUserQuackUpgrades(user)
console.log(allUserUpgrades)
return upgrade.preReqs.every(preReq => allUserUpgrades.includes(preReq))
}
const quackStoreText = user =>
Object.entries(quackStore)
.filter(hasPreReqs(user))
.map(quackStoreListing)
.join('\n\n')
const owns = (user, [name, upgrade]) => allUserQuackUpgrades(user).includes(name)
const quackStoreRoute = async ({ event, say, words }) => {
const user = getUser(event.user)
const ownedQuackItems = user => Object.entries(quackStore).filter(upgrade => owns(user, upgrade))
const unownedQuackItems = user => Object.entries(quackStore).filter(upgrade => !owns(user, upgrade))
const quackStoreText = user =>
unownedQuackItems(user)
.filter(hasPreReqs(user))
.map(quackStoreListing(true))
.join('\n\n') +
`\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, args, YEET }) => {
user.quackUpgrades ??= {}
const quacks = user.quacks ??= 0
if (!words[1]) {
if (!args[0] || !YEET) {
await say(quackStoreText(user))
return
}
const quackItem = quackStore[words[1]]
if (!quackItem) {
await say(`'${words[1]}' is not available in the quack store!`)
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
}
const quacks = user.quacks ??= 0
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]}!`)
}
const buyQuackGradeButton = quackgrade => {
//console.log('buyQuackGradeButton', quackgrade[1])
const [name, object] = quackgrade
return {
type: 'section',
text: {
type: 'mrkdwn',
text: `:${object.emoji}: ${object.name} - ${object.cost} Quacks\n_${object.description}_`
},
accessory: {
type: 'button',
text: {
type: 'plain_text',
text: 'Buy',
emoji: true
},
value: 'click_me_123',
action_id: `buy-quackgrade-${name}`
}
}
}
const prestigeMenu = (user, extraMessage = '') => {
user.quackUpgrades ??= {}
const quacks = user.quacks ??= 0
return {
text: 'Prestige menu',
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `${extraMessage && extraMessage + '\n'}\n_You have ${quacks} quacks to spend._`
// text: `${extraMessage && extraMessage + '\n'}_*PRESTIGE IN PROGRESS*_\n_You have ${quacks} quacks to spend. You may ONLY spend quacks on this menu._`
}
},
// {
// type: 'section',
// text: {
// type: 'mrkdwn',
// text: '~Challenge mode~ _TODO_'
// },
// accessory: {
// type: 'static_select',
// placeholder: {
// type: 'plain_text',
// text: 'No challenge',
// emoji: true
// },
// options: [
// {
// text: {
// type: 'plain_text',
// text: 'No challenge',
// emoji: true
// },
// value: 'no-challenge'
// },
// /*
// {
// text: {
// type: 'plain_text',
// text: 'Clean Start (no prestige bonuses)',
// emoji: true
// },
// value: 'clean-start'
// }*/
// ],
// action_id: 'challenge_select-action'
// }
// },
{
type: 'section',
text: {
type: 'mrkdwn',
text: '*Available Quackgrades:*'
}
},
...unownedQuackItems(user).filter(hasPreReqs(user)).map(buyQuackGradeButton),
// {
// type: 'actions',
// elements: [
// {
// type: 'button',
// text: {
// type: 'plain_text',
// text: 'Complete Prestige',
// emoji: true
// },
// value: 'complete_prestige',
// action_id: 'complete_prestige'
// }
// ]
// }
]
}
}
const buyQuackGrade = async ({ body, ack, say, trueSay, payload }) => {
await ack()
const buying = payload.action_id.substring('buy-quackgrade-'.length)
console.log(`buyQuackGrade ${buying} clicked`)
const user = getUser(body.user.id)
// if (!user.isPrestiging) {
// console.log('You must be prestiging!')
// return say(`You must be prestiging to use this menu!`)
// }
const words = ['', buying, body.actions[0].text]
const [, ...args] = words
let extraMessage = ''
//say = async text => extraMessage = text
console.log('quackStoreRoute')
await quackStoreRoute({ say, args, user, YEET: true })
await slack.app.client.chat.update({
channel: body.channel.id,
ts: body.message.ts,
...prestigeMenu(user, extraMessage)
})
}
Object.keys(quackStore).forEach(itemName => slack.app.action('buy-quackgrade-' + itemName, buyQuackGrade))
slack.app.action('complete_prestige', async ({ body, ack, say }) => {
await ack()
const user = getUser(body.user.id)
delete user.isPrestiging
await slack.app.client.chat.delete({
channel: body.channel.id,
ts: body.message.ts,
})
await say(`Prestige complete!`)
})
const prestigeMenuRoute = async ({ say, user }) => {
user.quackUpgrades ??= {}
user.quacks ??= 0
await say(prestigeMenu(user))
}
const ownedQuacksText = user =>
ownedQuackItems(user)
.filter(hasPreReqs(user))
.map(quackStoreListing(false))
.join('\n\n') +
`\n\nQuackStore upgrades are currently boosting your CPS by ${commas((quackGradeMultiplier(user) - 1) * 100)}%`
const ownedQuacksRoute = async ({ say, user }) => {
user.quackUpgrades ??= {}
user.quacks ??= 0
await say(ownedQuacksText(user))
}
module.exports = {
quackStoreRoute,
prestigeRoute,
prestigeConfirmRoute
prestigeConfirmRoute,
prestigeMenuRoute,
ownedQuacksRoute
}

View File

@ -1,3 +1,10 @@
const getRandomFromArray = array => array[Math.floor(Math.random() * array.length)]
const chaosCpsMods = [3, 2, 0.1, 1, 1.5, 1.6, 0, 1.1, 1.1, 1.26]
const chaosAvg = () => chaosCpsMods.reduce((total, next) => total + next, 0) / chaosCpsMods.length
//const getChaos = offset => chaosCpsMods[(Math.floor(new Date().getSeconds() / chaosCpsMods.length) + offset) % chaosCpsMods.length]
const getChaos = offset => chaosCpsMods[(Math.floor(new Date().getSeconds() / chaosCpsMods.length) + offset) % chaosCpsMods.length]
const quackStore = {
ascent: {
name: 'Ascent',
@ -11,10 +18,159 @@ const quackStore = {
name: 'Nuclear Fuel',
type: 'cps',
emoji: 'atom_symbol',
description: 'The future is now. Boosts all CPS by 20%.',
description: 'The future is now, old man. Boosts all CPS by 20%.',
preReqs: ['ascent'],
effect: cps => cps * 1.2,
cost: 5
},
chaos: {
name: 'Chaos',
type: 'cps',
emoji: 'eye',
description: 'Awaken. Gives a random modifier to your CPS every six seconds. May have other consequences...',
//+ '_\n_Averages a 26% CPS boost.',
preReqs: ['nuclearFuel'],
effect: (cps, user) => {
return cps * getChaos(Math.round(user.interactions / 50))
},
cost: 10
},
dryerSheet: {
name: 'Dryer Sheet',
type: 'lightning',
emoji: 'rose',
description: 'Smells nice. Makes lightning twice as likely to strike.',
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: ['ascent'],
effect: user => {
user.items.mouse ??= 0
user.items.mouse += 5
},
cost: 4
},
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: 16
},
sharkBoy: {
name: 'Shark Boy',
type: 'starter',
emoji: 'ocean',
description: 'Start each prestige with 5 whales',
preReqs: ['silverSpoon'],
effect: user => {
user.items.whale ??= 0
user.items.whale += 5
},
cost: 64
},
superClumpingLitter: {
name: 'Super-Clumping Cat Litter',
type: 'pet',
emoji: 'smirk_cat',
description: 'Extra-strength pet effects',
preReqs: ['sharkBoy'],
effect: petMultiplier => {
petMultiplier = Math.max(petMultiplier, 1)
return petMultiplier * petMultiplier
},
cost: 128
},
magnetMan: {
name: 'Magnet Man',
type: 'starter',
emoji: 'magnet',
description: 'Start each prestige with 5 Trains',
preReqs: ['sharkBoy'],
effect: user => {
user.items.train ??= 0
user.items.train += 5
},
cost: 256
},
catFan: {
name: 'Cat Fan',
type: 'pet',
emoji: 'cat',
description: 'Super extra-strength pet effects',
preReqs: ['magnetMan', 'superClumpingLitter'],
effect: petMultiplier => {
petMultiplier = Math.max(petMultiplier, 1)
return petMultiplier * petMultiplier
},
cost: 512
},
lavaGirl: {
name: 'Lava Girl',
type: 'starter',
emoji: 'volcano',
description: 'Start each prestige with 5 Fire',
preReqs: ['magnetMan'],
effect: user => {
user.items.fire ??= 0
user.items.fire += 5
},
cost: 1024
},
aussie: {
name: 'Aussie',
type: 'starter',
emoji: 'flag-au',
description: 'Start each prestige with 5 Boomerangs',
preReqs: ['lavaGirl'],
effect: user => {
user.items.boomerang ??= 0
user.items.boomerang += 5
},
cost: 4096
},
}
module.exports = {
quackStore,
getChaos: user => getChaos(user.interactions || 0)
}
module.exports = quackStore

View File

@ -0,0 +1,4 @@
module.exports = {
horrorEnabled: false,
admins: ['Sage']
}

View File

@ -1,227 +1,666 @@
const basic = ({ type, description, count, cost }) => ({
const { getCPS, setUpgrades } = require('./utils');
const basic = ({ name, type, description, count, cost, extraCondition = () => true, effect = cps => cps * 2 }) => ({
name,
type,
description,
condition: user => user.items[type] >= count,
condition: (user, squadGrades) => user.items[type] >= count && extraCondition(user, squadGrades),
cost,
effect: itemCps => itemCps * 2
effect
})
const evil = ({ name, type, description, cost }) => basic({
name,
type,
description,
count: 40,
cost,
extraCondition: (user, squadGrades) => squadGrades?.includes('discardHumanMorals'),
})
const heavenly = ({ name, type, description, cost, multiplier = 2 }) => ({
name,
type,
description,
condition: (user, squadGrades) => user.items[type] >= 60 && squadGrades?.includes('redemption'),
cost,
effect: cps => cps * multiplier
})
const disabled = () => false
const baby = ({ name, type, description, cost }) => basic({
name,
type,
description,
count: 70,
cost,
extraCondition: disabled
})
const geometry = ({ name, type, description, cost }) => basic({
name,
type,
description,
count: 100,
cost,
extraCondition: disabled
})
const universitality = ({ name, type, description, cost }) => basic({
name,
type,
description,
count: 100,
cost,
extraCondition: disabled
})
module.exports = {
doubleClick: basic({
name: 'Double-Click',
type: 'mouse',
description: 'Doubles the power of mice',
count: 1,
cost: 1_000,
cost: 1_000
}),
stinkierCheese: basic({
name: 'Stinkier Cheese',
type: 'mouse',
description: 'Mice are doubly motivated to hunt down HVAC Coins',
count: 10,
cost: 21_000,
cost: 21_000
}),
biggerTeeth: basic({
name: 'Bigger Teeth',
type: 'mouse',
description: 'Mice can intimidate twice as much HVAC out of their victims.',
count: 25,
cost: 50_000,
cost: 50_000
}),
rats: evil({
name: 'Rats',
type: 'mouse',
description: 'Consume the rotten remains of your foes',
cost: 150_000,
}),
hoodedMice: heavenly({
name: 'Hooded Mice',
type: 'mouse',
description: 'These monks have nearly reached enlightenment. 10x Mouse CPS.',
cost: 1_000_000,
multiplier: 10,
}),
babyMouse: baby({
name: 'Baby Mouse',
type: 'mouse',
description: 'Squeak!',
cost: 6_000_000,
}),
fasterComputers: basic({
name: 'Faster Computers',
type: 'accountant',
description: 'Accountants can ~steal~ optimize twice as much HVAC!',
count: 1,
cost: 11_000,
}),
lackOfMorality: basic({
name: 'Lack of Morality',
type: 'accountant',
description: 'Accountants are taking a hint from nearby CEOs.',
count: 10,
cost: 200_000,
}),
widerBrains: basic({
name: 'Wider Brains',
type: 'accountant',
description: 'For accountant do double of thinking.',
count: 25,
cost: 550_000,
}),
vastLayoffs: evil({
name: 'Vast Layoffs',
type: 'accountant',
description: 'The weak are not part of our future.',
cost: 2_450_000,
}),
charityFund: heavenly({
name: 'Charity Fund',
type: 'accountant',
description: 'THIS one is more than just a tax break. 9x Accountant CPS.',
cost: 16_333_333,
multiplier: 9,
}),
mathBaby: baby({
name: 'Math Baby',
type: 'accountant',
description: '2 + 2 = WAAH!',
cost: 99_999_999,
}),
biggerBlowhole: basic({
name: 'Bigger Blowhole',
type: 'whale',
description: 'With all that extra air, whales have double power!',
count: 1,
cost: 120_000,
cost: 120_000
}),
sassyWhales: basic({
name: 'Sassy Whales',
type: 'whale',
description: 'These are the kind of whales that know how to get twice as much done',
count: 10,
cost: 3_000_000,
cost: 3_000_000
}),
thinnerWater: basic({
name: 'Thinner Water',
type: 'whale',
description: 'Whales can move twice as quickly through this physics-defying liquid',
count: 25,
cost: 6_000_000,
cost: 6_000_000
}),
blightWhales: evil({
name: 'Blight Whales',
type: 'whale',
description: `Infectious with evil, they swim the ocean spreading their spores.`,
cost: 24_000_000
}),
whaleChoir: heavenly({
name: 'Whale Choir',
type: 'whale',
description: `Their cleansing songs reverberate through the sea. 8x Whale CPS.`,
cost: 144_000_000,
multiplier: 8,
}),
smolWhales: baby({
name: 'Smol Whales',
type: 'whale',
description: ``,
cost: 8_400_000_000
}),
greasyTracks: basic({
name: 'Greasy Tracks',
type: 'train',
description: 'Lets trains deliver HVAC twice as efficiently',
count: 1,
cost: 1_300_000,
cost: 1_300_000
}),
rocketThrusters: basic({
name: 'Rocket Thrusters',
type: 'train',
description: 'That\'ll put some quack on your track',
count: 10,
cost: 22_000_000,
cost: 22_000_000
}),
loudConductors: basic({
name: 'Loud Conductors',
type: 'train',
description: 'Conductors can onboard twice as much HVAC',
count: 25,
cost: 65_000_000,
cost: 65_000_000
}),
hellTrain: evil({
name: 'Hell Train',
type: 'train',
description: 'Shipping blood needed for the ritual.',
cost: 370_000_000
}),
toyTrain: heavenly({
name: 'Toy Train',
type: 'train',
description: 'Toot toot! 8x Train CPS.',
multiplier: 8,
cost: 2_220_000_000
}),
gasolineFire: basic({
name: 'Gasoline Fire',
type: 'fire',
description: 'Extremely good for breathing in.',
count: 1,
cost: 14_000_000,
cost: 14_000_000
}),
extremelyDryFuel: basic({
name: 'Extremely Dry Fuel',
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,
cost: 163_000_000
}),
cavemanFire: basic({
name: 'Caveman Fire',
type: 'fire',
description: 'They just don\'t make \'em like they used to.',
count: 25,
cost: 700_000_000,
cost: 700_000_000
}),
lava: evil({
name: 'Lava',
type: 'fire',
description: `Hopefully no usurpers have any "accidents".`,
cost: 4_200_000_000
}),
blueFire: heavenly({
name: 'Blue Fire',
type: 'fire',
description: `You can hear it singing with delight. 7x Fire CPS.`,
multiplier: 7,
cost: 25_200_000_000
}),
cuteFire: baby({
name: 'Cute Fire',
type: 'fire',
description: `I just met my perfect match...`,
cost: 150_000_000_000
}),
spoonerang: basic({
name: 'Spoonerang',
type: 'boomerang',
description: 'Scoops up HVAC mid-flight',
count: 1,
cost: 200_000_000,
cost: 200_000_000
}),
boomerAng: basic({
name: 'Boomer-ang',
type: 'boomerang',
description: 'It\'s... old.',
count: 10,
cost: 1_200_000_000,
cost: 1_200_000_000
}),
doubleRang: basic({
name: 'Double-rang',
type: 'boomerang',
description: 'You throw one, but somehow catch two',
count: 25,
cost: 10_000_000_000,
cost: 10_000_000_000
}),
loyalRang: evil({
name: 'Loyal-rang',
type: 'boomerang',
description: `Frequently reports back to your throne on the state of your empire.`,
cost: 60_000_000_000
}),
youRang: heavenly({
name: 'You-rang',
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._\n_7x Boomerang CPS.',
multiplier: 7,
cost: 360_000_000_000
}),
lunarPower: basic({
name: 'Lunar Power',
type: 'moon',
description: 'Out with the sol, in with the lun!',
count: 1,
cost: 3_300_000_000,
cost: 3_300_000_000
}),
womanOnTheMoon: basic({
name: 'Woman on the Moon',
type: 'moon',
description: 'There\'s no reason for it not to be a woman!',
count: 10,
cost: 39_700_000_000,
cost: 39_700_000_000
}),
doubleCraters: basic({
name: 'Double-Craters',
type: 'moon',
description: 'Making every side look like the dark side.',
count: 25,
cost: 165_000_000_000,
cost: 165_000_000_000
}),
tidalUpheaval: evil({
name: 'Tidal Upheaval',
type: 'moon',
description: `The hell with the ocean. That's valuable_ *the abstract concept of more power* _we're losing.`,
cost: 865_000_000_000
}),
newMoon: heavenly({
name: 'New Moon',
type: 'moon',
description: `Build a second moon to provide space for affordable housing. 6x Moon CPS.`,
multiplier: 6,
cost: 5_190_000_000_000
}),
glassButterfly: basic({
name: 'Glass Butterfly',
type: 'butterfly',
description: 'Not your grandma\'s universe manipulation.',
count: 1,
cost: 51_000_000_000,
cost: 51_000_000_000
}),
monarchMigration: basic({
name: 'Monarch Migration',
type: 'butterfly',
description: 'This upgrade brought to you by milkweed.',
count: 10,
cost: 870_000_000_000,
cost: 870_000_000_000
}),
quadWing: basic({
name: 'Quad-Wing',
type: 'butterfly',
description: 'Sounds a lot like a trillion bees buzzing inside your head.',
count: 25,
cost: 2_550_000_000_000,
cost: 2_550_000_000_000
}),
venomousMoths: evil({
name: 'Venomous Moths',
type: 'butterfly',
description: 'Specifically manufactured for their horrifying brain-melt toxins.',
cost: 12_550_000_000_000
}),
quietingNectar: heavenly({
name: 'Quieting Nectar',
type: 'butterfly',
description: 'Calming and extra sweet. Soothes even human ails. 6x Butterfly CPS.',
multiplier: 6,
cost: 75_300_000_000_000
}),
silverMirror: basic({
name: 'Silver Mirror',
type: 'mirror',
description: 'Excellent for stabbing vampires.',
count: 1,
cost: 750_000_000_000,
cost: 750_000_000_000
}),
pocketMirror: basic({
name: 'Pocket Mirror',
type: 'mirror',
description: 'Take your self-reflection on the go!',
count: 10,
cost: 18_000_000_000_000
}),
window: basic({
name: 'Window',
type: 'mirror',
description: 'Only through looking around you can you acquire the self reflection necessary to control the thermostat.',
count: 25,
cost: 37_500_000_000_000,
cost: 37_500_000_000_000
}),
crackedMirror: evil({
name: 'Cracked Mirror',
type: 'mirror',
description: `YOU SMILE. DO NOT FEAR, THIS IS THE FACE OF A FRIEND.`,
cost: 222_000_000_000_000
}),
funHouseMirror: heavenly({
name: 'Fun-House Mirror',
type: 'mirror',
description: `yoU LOok so siLLY IN thesE THINgs. 5X mIRror CpS.`,
multiplier: 5,
cost: 1_330_000_000_000_000
}),
fzero: basic({
name: 'F-Zero',
type: 'quade',
description: 'Brings out his competitive spirit.',
count: 1,
cost: 10_000_000_000_000,
cost: 10_000_000_000_000
}),
triHumpCamel: basic({
name: 'Trimedary Camel',
type: 'quade',
description: 'YEE HAW :trimedary_camel:',
count: 10,
cost: 200_000_000_000_000
}),
adam: basic({
name: 'Adam',
type: 'quade',
description: 'He could probably reach the thermostat if he wanted.',
count: 25,
cost: 500_000_000_000_000,
cost: 500_000_000_000_000
}),
thatsNotQuade: evil({
name: `That's not Quade...`,
type: 'quade',
description: `The skinless face lacks even a moustache. Nevertheless, it pledges its allegiance.`,
cost: 3_000_000_000_000_000
}),
hannahMontanaLinux: heavenly({
name: 'Hannah Montana Linux',
type: 'quade',
description: `The patrician's choice. 4x Quade CPS.`,
multiplier: 4,
cost: 18_000_000_000_000_000
}),
latestNode: basic({
name: 'Latest Node',
type: 'hvacker',
description: 'The old one has terrible ergonomics, tsk tsk.',
count: 1,
cost: 140_000_000_000_000,
cost: 140_000_000_000_000
}),
nativeFunctions: basic({
name: 'Native Functions',
type: 'hvacker',
description: 'Sometimes javascript just isn\'t fast enough.',
count: 10,
cost: 3_300_000_000_000_000
}),
gitCommits: basic({
name: 'Git Commits',
type: 'hvacker',
description: 'The heads of multiple people in a company are better than, for example, merely one head.',
count: 25,
cost: 7_000_000_000_000_000,
cost: 7_000_000_000_000_000
}),
undefinedBehavior: evil({
name: 'Undefined Behavior',
type: 'hvacker',
description: `skREEEFDS☐☐☐☐☐it's☐jwtoo☐laate☐☐☐☐☐`,
cost: 42_000_000_000_000_000
}),
mutualUnderstanding: heavenly({
name: 'Mutual Understanding',
type: 'hvacker',
description: `lol fat chance, dummy. Points for trying, though. 3x Hvacker CPS`,
multiplier: 3,
cost: 250_000_000_000_000_000
}),
coffee: basic({
name: 'Coffee',
type: 'creator',
description: `Didn't you know? It makes you smarter. No consequencAAAAAA`,
count: 1,
cost: 1_960_000_000_000_000
}),
bribery: basic({
name: 'Bribery',
type: 'creator',
description: `How much could he be making that a couple bucks won't get me more HVAC?`,
count: 10,
cost: 32_300_000_000_000_000
}),
vim: basic({
name: 'Vim',
type: 'creator',
description: `*teleports behind you*`,
count: 25,
cost: 100_000_000_000_000_000
}),
regrets: evil({
name: 'Regrets',
type: 'creator',
description: `HE HAS NONE. HE LAUGHS.`,
cost: 600_000_000_000_000_000
}),
goVegan: heavenly({
name: 'Go Vegan',
type: 'creator',
description: `Unlock your vegan powers. 3x Creator CPS.`,
multiplier: 3,
cost: 3_600_000_000_000_000_000
}),
angelInvestors: basic({
name: 'Angel Investors',
type: 'smallBusiness',
description: 'Not so small NOW are we?',
count: 1,
cost: 3_140_000_000_000_000
}),
officeManager: basic({
name: 'Office Manager',
type: 'smallBusiness',
description: 'Sate your laborers with snacks.',
count: 10,
cost: 80_000_000_000_000_000
}),
undyingLoyalty: basic({
name: 'Undying Loyalty',
type: 'smallBusiness',
description: 'Your foolish employees bow to your every whim, regardless of salary.',
count: 25,
cost: 138_000_000_000_000_000
}),
deathSquad: evil({
name: 'Death Squad',
type: 'smallBusiness',
description: `pwease don't unionize uwu :pleading_face:`,
cost: 858_000_000_000_000_000
}),
coop: heavenly({
name: 'Co-Op',
type: 'smallBusiness',
description: `By the people, for the people. 2x smallBusiness CPS`,
multiplier: 2,
cost: 5_140_000_000_000_000_000
}),
corporateBuyouts: basic({
name: 'Corporate Buyouts',
type: 'bigBusiness',
description: 'The cornerstone of any family-run business.',
count: 1,
cost: 28_140_000_000_000_000
}),
politicalSway: basic({
name: 'Political Sway',
type: 'bigBusiness',
description: `What's a bit of lobbying between friends?`,
count: 10,
cost: 560_000_000_000_000_000
}),
humanDiscontent: basic({
name: 'Human Discontent',
type: 'bigBusiness',
description: 'A sad populace is a spendy populace!',
count: 25,
cost: 1_372_000_000_000_000_000
}),
weJustKillPeopleNow: evil({
name: 'We Just Kill People Now',
type: 'bigBusiness',
description: 'It is extremely difficult to get more evil than we already were. Nevertheless,',
cost: 7_072_000_000_000_000_000
}),
makePublic: heavenly({
name: 'Make Public',
type: 'bigBusiness',
description: `Downplay immediate profit for more long-term benefits. 2x bigBusiness CPS.`,
multiplier: 2,
cost: 42_000_000_000_000_000_000
}),
community: basic({
name: 'Community',
type: 'government',
description: `In a sense, this is really all you need.`,
count: 1,
cost: 280_140_000_000_000_000
}),
theState: basic({
name: 'The State',
type: 'government',
description: 'Congratulations, you have a monopoly on violence.',
count: 10,
cost: 5_060_000_000_000_000_000
}),
openBorders: basic({
name: 'Open Borders',
type: 'government',
description: 'Cigars, anyone?',
count: 25,
cost: 9_999_999_999_999_999_999
}),
capitalism: evil({
name: 'Capitalism',
type: 'government',
description: 'Obviously this is the ideal economy no further questions thank you.',
cost: 70_072_000_000_000_000_000
}),
socialism: heavenly({
name: 'Socialism',
type: 'government',
description: `A dictatorship of the proletariat. And thank god.`,
multiplier: 2,
cost: 690_000_000_000_000_000_000
}),
homage: {
name: 'Homage',
type: 'general',
description: 'The power of original ideas increases your overall CPS by 10%',
condition: user => Object.entries(user.items).reduce((total, [, countOwned]) => countOwned + total, 0) >= 200,
emoji: 'cookie',
cost: 10_000_000_000,
effect: (itemCps, user) => Math.ceil(itemCps * 1.1)
effect: (itemCps, user) => itemCps * 1.1
},
iLoveHvac: {
name: 'iLoveHvac',
type: 'general',
description: 'The power of love increases your overall CPS by 10%',
condition: user => Object.entries(user.items).reduce((total, [, countOwned]) => countOwned + total, 0) >= 400,
emoji: 'heart',
cost: 100_000_000_000_000,
effect: (itemCps, user) => Math.ceil(itemCps * 1.1)
}
effect: (itemCps, user) => itemCps * 1.1
},
// moreUpgrades: {
// type: 'general',
// description: 'Adds additional upgrades',
// condition: user => Object.entries(user.items).reduce((total, [, countOwned]) => countOwned + total, 0) >= 400,
// emoji: 'cookie',
// cost: 10_000_000_000_000,
// effect: nothing
// },
digitalPickaxe: {
name: 'Digital Pickaxe',
type: 'mining',
description: 'Break coinful digirocks into bits, increasing the power of !mine',
condition: user => user.interactions > 100,
emoji: 'pick',
cost: 100_000,
effect: (mineTotal, user) => mineTotal + (getCPS(user) * 0.1)
},
vacuum: {
name: 'Digital Vacuum',
type: 'mining',
description: 'Suck up leftover HVAC dust, greatly increasing the power of !mine',
condition: user => user.interactions > 500,
emoji: 'vacuum',
cost: 10_000_000,
effect: (mineTotal, user) => mineTotal + (getCPS(user) * 0.1)
},
mineCart: {
name: 'HVAC Mine Cart',
type: 'mining',
description: 'You\'d shine like a diamond, down in the !mine',
condition: user => user.interactions > 1500,
emoji: 'shopping_trolley',
cost: 100_000_000_000,
effect: (mineTotal, user) => mineTotal + (getCPS(user) * 0.1)
},
fpga: {
name: 'FPGA Miner',
type: 'mining',
description: 'Wait, what kind of mining is this again?',
condition: user => user.interactions > 5000,
emoji: 'floppy_disk',
cost: 1_000_000_000_000_000,
effect: (mineTotal, user) => mineTotal + (getCPS(user) * 0.1)
}
}
setUpgrades(module.exports)

View File

@ -1,37 +1,79 @@
const fs = require('fs')
const jokes = require('../jokes')
const achievements = require('./achievements');
const buyableItems = require("./buyableItems");
const upgrades = require("./upgrades");
const quackStore = require("./quackstore");
//const jokes = require('../jokes')
const achievements = require('./achievements')
const buyableItems = require('./buyableItems')
const { quackStore, getChaos } = require('./quackstore')
let upgrades
const setUpgrades = upg => {
upgrades = upg
}
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 = () => parseOr(fs.readFileSync('./' + saveFile, 'utf-8'),
const loadGame = () => {
const game = parseOr(fs.readFileSync('./' + saveFile, 'utf-8'),
() => ({
users: {},
nfts: [],
squad: {}
squad: {},
horrors: {}
}))
game.horrors ??= {}
return game
}
const parseOr = (parseable, orFunc) => {
const chaosFilter = (num, odds, user, max = Infinity, min = -Infinity) => {
const userQuackgrades = user.quackUpgrades?.cps || []
const hasChaos = userQuackgrades.includes('chaos')
if (!hasChaos || Math.random() < odds || !num) {
return num
}
const chaosed = num * getChaos(user)
if (chaosed > max) {
return max
}
if (chaosed < min) {
return min
}
return chaosed
}
const parseOr = (parseable, fallback) => {
try {
if (typeof parseable === 'function') {
parseable = parseable()
}
return JSON.parse(parseable)
} catch (e) {
logError(e)
return orFunc()
return fallback()
}
}
const makeBackup = () => {
const fileName = './backups/' + saveFile + new Date().toLocaleString().replace(/[^a-z0-9]/gi, '_')
console.log(`Making backup file: ${fileName}`)
fs.writeFileSync(fileName, JSON.stringify(game))
}
let saves = 0
const saveGame = () => {
if (saves % 100 === 0) {
fs.writeFileSync('./backups/' + saveFile + new Date().toLocaleString().replace(/[^a-z0-9]/gi, '_'), JSON.stringify(game))
const saveGame = (after, force = true) => {
if (saves % 20 === 0) {
makeBackup()
}
saves += 1
if (force || saves % 10 === 0) {
if (after) {
console.log(`SAVING GAME after ${after}`)
} else {
console.log('SAVING GAME')
}
fs.writeFileSync('./' + saveFile, JSON.stringify(game, null, 2))
}
}
const maybeNews = say => {
@ -47,14 +89,116 @@ const maybeNews = say => {
const idFromWord = word => {
if (!word?.startsWith('<@') || !word.endsWith('>')) {
return null
}
return getIdFromName(word)
} else {
return word.substring(2, word.length - 1)
}
}
const getSeconds = () => new Date().getTime() / 1000
const commas = num => num.toLocaleString()
const bigNumberWords = [
['tredecillion', 1_000_000_000_000_000_000_000_000_000_000_000_000_000_000],
['duodecillion', 1_000_000_000_000_000_000_000_000_000_000_000_000_000],
['undecillion', 1_000_000_000_000_000_000_000_000_000_000_000_000],
['decillion', 1_000_000_000_000_000_000_000_000_000_000_000],
['nonillion', 1_000_000_000_000_000_000_000_000_000_000],
['octillion', 1_000_000_000_000_000_000_000_000_000],
['septillion', 1_000_000_000_000_000_000_000_000],
['sextillion', 1_000_000_000_000_000_000_000],
['quintillion', 1_000_000_000_000_000_000],
['quadrillion', 1_000_000_000_000_000],
['trillion', 1_000_000_000_000],
['billion', 1_000_000_000],
['million', 1_000_000],
]
const commas = (num, precise = false, skipWords = false) => {
num = Math.round(num)
if (num === 1) {
return 'one'
}
const bigNum = bigNumberWords.find(([, base]) => num >= base)
if (bigNum && !precise) {
const [name, base] = bigNum
const nummed = (num / base).toPrecision(3)
if (skipWords) {
return nummed
}
return `${nummed} ${name}`
}
return num.toLocaleString()
}
const parseAll = (str, allNum, user) => {
if (!str) {
return NaN
}
str = str?.toLowerCase()?.replace(/,/g, '') || '1'
switch (str) {
case 'all':
case 'sugma':
case 'ligma':
case 'pulma':
case 'deez':
case 'max_int':
case 'my soul':
return allNum
case 'sex':
case 'sex number':
return 69_000_000
case ':maple_leaf:':
case ':herb:':
case 'weed':
case 'weed number':
return 420_000_000
case 'a milli':
return 1_000_000
case 'a band':
return 1000
case ':100:':
case 'one hunna':
return 100
}
if (user && buyableItems[str]) {
return calculateCost({ itemName: str, user, quantity: 1 })
}
console.log('STR', str)
if (str.match(/^\d+$/)) {
return parseInt(str)
}
if (allNum && str.match(/^\d+%$/)) {
const percent = parseFloat(str) / 100
if (percent > 1 || percent < 0) {
return NaN
}
return Math.round(percent * allNum)
}
if (str.match(/^\d+\.\d+$/)) {
return Math.round(parseFloat(str))
}
const bigNum = bigNumberWords.find(([name]) => str.endsWith(name))
if (bigNum && str.match(/^\d+(\.\d+)?/)) {
return Math.round(parseFloat(str) * bigNum[1])
}
return NaN
}
const calculateCost = ({ itemName, user, quantity = 1 }) => {
let currentlyOwned = user.items[itemName] || 0
let realCost = 0
for (let i = 0; i < quantity; i++) {
realCost += Math.ceil(buyableItems[itemName].baseCost * Math.pow(1.15, currentlyOwned || 0))
currentlyOwned += 1
}
return realCost
}
const game = loadGame()
const { users, nfts, squad } = game
@ -76,50 +220,60 @@ const addAchievement = (user, achievementName, say) => {
}
setTimeout(async () => {
user.achievements[achievementName] = true
saveGame()
saveGame(`${user.name} earned ${achievementName}`)
await say(`You earned the achievement ${achievements[achievementName].name}!`)
}, 500)
}
const getUser = userId => {
if (!users[userId]) {
users[userId] = {
coins: 0,
items: {},
upgrades: {},
achievements: {},
coinsAllTime: 0,
prestige: 0
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]
}
} else {
return null;
}
const getUser = (userId, updateCoins = false) => {
users[userId] ??= {}
users[userId].coins ??= 0
users[userId].items ??= {}
users[userId].upgrades ??= {}
users[userId].achievements ??= {}
users[userId].coinsAllTime ??= users[userId].coins
users[userId].prestige ??= 0
users[userId].startDate ??= new Date()
if (updateCoins) {
users[userId].coins = getCoins(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(userId) * 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
}
const getCPS = userId => {
const user = getUser(userId)
const getCPS = user => {
const userItems = user?.items || {}
return Math.round(Object.keys(userItems).reduce((total, itemName) => total + getItemCps(user, itemName), 0))
}
@ -130,16 +284,31 @@ const squadUpgrades = {
tastyKeyboards: {
name: 'Tasty Keyboards',
description: 'Delicious and sticky. Boosts CPS by 20% for everyone.',
effect: cps => Math.ceil(cps * 1.2),
effect: cps => cps * 1.2,
cost: 10_000_000_000_000,
emoji: 'keyboard'
},
copyPasteMacro: {
name: 'Copy-Paste Macro.',
description: 'Don\'t actually use this. Boosts CPS by 20% for everyone.',
effect: cps => Math.ceil(cps * 1.2),
effect: cps => cps * 1.2,
cost: 100_000_000_000_000,
emoji: 'printer'
},
discardHumanMorals: {
name: 'Neglect human decency',
description: `Unlocks a new tier of upgrades, but at what cost?`,
effect: cps => cps * 1.1,
cost: 100_000_000_000_000_000,
emoji: 'hole'
},
redemption: {
name: 'Redemption',
description: 'Can you return from the depths of depravity and save your soul?',
effect: cps => cps * 1.1,
cost: 1_000_000_000_000_000_000,
emoji: 'people_hugging'
}
}
@ -151,31 +320,234 @@ const getCompletedSquadgrades = () =>
.filter(squadHas)
.map(([, upgrade]) => upgrade)
const getCompletedSquadgradeNames = () =>
Object.entries(squadUpgrades)
.filter(squadHas)
.map(([name]) => name)
const prestigeMultiplier = user => 1 + ((user.prestige || 0) * 0.01)
const quackGradeMultiplier = user => {
const userQuackgrades = user.quackUpgrades?.cps || []
return userQuackgrades.reduce((total, upgrade) => quackStore[upgrade].effect(total, user), 1)
}
const petQuackGradeMultiplier = user => {
const userQuackgrades = user.quackUpgrades?.pet || []
return userQuackgrades.reduce((total, upgrade) => quackStore[upgrade].effect(total, user), petBoost())
}
const singleItemCps = (user, itemName) => {
const baseCps = buyableItems[itemName].earning
// console.log('')
// console.log(`${itemName} CPS:`)
// console.log('baseCps', baseCps)
const itemUpgrades = (user.upgrades[itemName] || []).map(name => upgrades[name])
const itemUpgradeCps = itemUpgrades.reduce((totalCps, upgrade) => upgrade.effect(totalCps, user), baseCps)
const itemUpgradeCps = itemUpgrades.reduce((totalCps, upgrade) => upgrade.effect(totalCps, user), 1)
// console.log('itemUpgradeCps', itemUpgradeCps)
const userGeneralUpgrades = user.upgrades.general || []
const generalUpgradeCps = Object.entries(userGeneralUpgrades).reduce((total, [, upgradeName]) => upgrades[upgradeName].effect(total, user), itemUpgradeCps)
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)
const achievementCount = Object.keys(user.achievements || {}).length
const achievementMultiplier = Math.pow(1.01, achievementCount)
// console.log('achievementMultiplier', achievementMultiplier)
const userQuackgrades = user.quackUpgrades?.cps || []
const quackMultiplier = userQuackgrades.reduce((total, upgrade) => quackStore[upgrade].effect(total, user), 1)
const quackGrade = quackGradeMultiplier(user)
// console.log('quackgrade', quackGrade)
const prestigeMultiplier = 1 + ((user.prestige || 0) * 0.01)
const pMult = prestigeMultiplier(user)
// console.log('prestigeMultiplier', pMult)
return achievementMultiplier *
quackMultiplier *
prestigeMultiplier *
getCompletedSquadgrades().reduce((cps, upgrade) => upgrade.effect(cps), generalUpgradeCps)
const squadGradeMultiplier = getCompletedSquadgrades().reduce((cps, upgrade) => upgrade.effect(cps), 1)
// console.log('squadGradeMultiplier', squadGradeMultiplier)
const petMultiplier = petQuackGradeMultiplier(user)
//console.log('petMultiplier', petMultiplier)
const total =
baseCps *
achievementMultiplier *
itemUpgradeCps *
generalUpgradeCps *
quackGrade *
pMult *
squadGradeMultiplier *
petMultiplier
// console.log('Single Item CPS:', total)
return total
}
const shuffle = str => str.split('').sort(() => 0.5 - Math.random()).join('')
const shufflePercent = (str, percentOdds) => {
const shuffled = shuffle(str)
let partiallyShuffled = ''
const shuffleChar = () => Math.random() < percentOdds
let isEmoji = false
for (let i = 0; i < str.length; i++) {
if (str[i] === ':') {
isEmoji = !isEmoji
}
if (isEmoji) { // Less likely to shuffle emojis
partiallyShuffled += (shuffleChar() && shuffleChar()) ? shuffled[i] : str[i]
} else {
partiallyShuffled += shuffleChar() ? shuffled[i] : str[i]
}
}
return partiallyShuffled
}
const definitelyShuffle = (str, percentOdds) => {
if (!str) {
return str
}
if (!percentOdds) {
percentOdds = 0.01
}
let shuffled = str
while (shuffled === str) {
shuffled = shufflePercent(str, percentOdds)
console.log('Shuffling... "' + shuffled + '"')
}
return shuffled
}
const getRandomFromArray = array => array[Math.floor(Math.random() * array.length)]
/**
* Adds reactions to the given message, in order.
* If adding any reaction is a failure, it will continue on to the next.
*
* @param app The slack bolt app
* @param channelId The id of the channel the message is in
* @param timestamp The timestamp of the message
* @param reactions An array of reactions to add
* @returns {Promise<void>}
*/
const addReactions = async ({ app, channelId, timestamp, reactions }) => {
for (const reaction of reactions) {
try {
await app.client.reactions.add({
channel: channelId,
timestamp,
name: reaction
})
} catch (e) {
logError(e)
}
}
}
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)
const petBoost = () => {
// game.pet ??= makePet()
const stats = Object.values(game.pet)
const hasTerribleStat = stats.filter(value => value < 1).length > 0
const averageStat = stats.reduce((total, current) => total + current, 0) / stats.length
if (hasTerribleStat && averageStat < 3) {
return 0.9
}
if (averageStat === 10) {
return 1.3
}
if (!hasTerribleStat && averageStat > 8) {
return 1.1
}
return 1
}
game.channelMaps ??= {}
let slackAppClientChatUpdate
/**
*
* @param name String name for this channel map
* @param text String of to send. Passed into slack.app.client.chat.update
* @param blocks Slack blocks object to send. Passed into slack.app.client.chat.update
* @param channel An (optional) new channel to add to the given map
* @param ts The timestamp of the message in the new channel to update
*/
const updateAll = async ({ name, text, blocks, add: { channel, ts } = {} }) => {
const channelMap = (game.channelMaps[name] ??= {})
// if (channel && ts && !channelMap[channel]) {
// }
if (channel && ts) {
channelMap[channel] = ts
console.log({ channelMap })
}
if (text || blocks) {
await Promise.all(Object.entries(channelMap).map(async ([channel, ts]) =>
slackAppClientChatUpdate({
channel,
ts,
text,
blocks
}).catch(e => {
console.error(e)
if (e.toString().includes('message_not_found')) {
delete channelMap[channel]
saveGame(`removing message ${channel}::${ts} from the ${name} list`)
}
})
))
}
// // const alreadyHas = !!channelMap[channel]
// if (channel && ts) {
// channelMap[channel] = ts
// console.log({ channelMap })
// }
// // return alreadyHas
}
module.exports = {
saveGame,
makeBackup,
logError,
parseOr,
maybeNews,
@ -190,5 +562,25 @@ module.exports = {
getItemCps,
squadUpgrades,
squadIsMissing,
game
prestigeMultiplier,
quackGradeMultiplier,
shufflePercent,
definitelyShuffle,
parseAll,
getRandomFromArray,
chaosFilter,
addReactions,
getCompletedSquadgradeNames,
game,
dayOfYear,
daysSinceEpoch,
userHasCheckedQuackgrade,
fuzzyMatcher,
addCoins,
calculateCost,
setKnownUsers: users => knownUsers = users,
petBoost,
updateAll,
setSlackAppClientChatUpdate: update => slackAppClientChatUpdate = update,
setUpgrades
}

View File

@ -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)
@ -46,13 +55,22 @@ const addCommand = ({ commandNames, helpText, action, condition, hidden }) => {
console.log(' rate limited')
return
}
console.log(` went through for ${slack.ourUsers[event.user]}`)
userGetter.users[event.user].lastApiCall = currentTime
console.log(` went through for ${slack.users[event.user]}`)
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.Admin) })
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 =>

View File

@ -1,43 +1,63 @@
const slack = require('../slack')
const { updateAll } = require('../games/hvacoins/utils')
const tie = 'TIE'
const messageFromBoard = ({ dataName, gameName, textFromBoard, board, player1, player2 }) =>
gameName + ' between ' + player1.toUpperCase() + ' and ' + player2.toUpperCase() + ' ' + encodeGame(dataName, board, [player1, player2]) + '\n' +
const messageFromBoard = ({ dataName, gameName, textFromBoard, board, player1, player2, channelMap }) =>
gameName + ' between ' + player1.toUpperCase() + ' and ' + player2.toUpperCase() + ' ' + encodeGame(dataName, board, [player1, player2], channelMap) + '\n' +
'```' + textFromBoard(board) + '\n```'
const addChoiceEmojis = async ({ choices, channel, ts }) => {
const addEmoji = async emojiName =>
const addEmoji = async emojiName => {
try {
await slack.app.client.reactions.add({
channel,
timestamp: ts,
name: emojiName
})
} catch (ignore) {
}
}
for (const choice of choices) {
await addEmoji(choice)
}
}
const buildGameStarter = ({ startTriggers, dataName, gameName, textFromBoard, initialBoard, turnChoiceEmojis }) => async ({ event, say }) => {
if (event.channel_type === 'im') {
if (event.channel_type !== 'im') {
return;
}
const eventText = event.text?.toLowerCase()
if (eventText && startTriggers.find(keyword => eventText.startsWith('!' + keyword))) {
if (!(eventText && startTriggers.find(keyword => eventText.startsWith('!' + keyword)))) {
return;
}
try {
console.log('Trigger found')
const opponent = event.text.toUpperCase().match(/<@[^>]*>/)[0]
const msg = messageFromBoard({
console.log('Messaging opponent ' + slack.users[opponent.substring(2, opponent.length - 1)])
const channelMap = {}
const msg = () => messageFromBoard({
dataName,
gameName,
textFromBoard,
board: initialBoard(),
player1: '<@' + event.user + '>',
player2: opponent
player2: opponent,
channelMap
})
const sent = await say(msg)
await addChoiceEmojis({ ...sent, choices: turnChoiceEmojis })
const sent = await say(msg())
channelMap[event.user] = {
channel: sent.channel,
ts: sent.ts
}
await updateAll({ name: gameName, text: msg(), add: sent })
await addChoiceEmojis({...sent, choices: turnChoiceEmojis})
} catch (e) {
console.error(e)
}
}
const encodeGame = (dataKey, board, players) => slack.encodeData(dataKey, { board, players })
const encodeGame = (dataKey, board, players, channelMap = {}) => slack.encodeData(dataKey, { board, players, channelMap })
const decodeGame = (dataKey, message) => slack.decodeData(dataKey, message)
@ -50,7 +70,8 @@ const getMessages = winner => {
}
const buildTurnHandler = ({ gameName, dataName, checkWinner, textFromBoard, turnChoiceEmojis, makeMove }) => async ({ event, say }) => {
if (event.item_user !== slack.hvackerBotUserId || !turnChoiceEmojis.includes(event.reaction)) {
if (event.item_user !== slack.users.Hvacker || !turnChoiceEmojis.includes(event.reaction)) {
console.log('bad item_user/reaction')
return
}
@ -61,12 +82,15 @@ const buildTurnHandler = ({ gameName, dataName, checkWinner, textFromBoard, turn
const game = decodeGame(dataName, message.messages[0].text)
if (!game) {
console.log('could not decode game')
return
}
const { board, players } = game
game.channelMap ??= {}
const { board, players, channelMap } = game
let winner = checkWinner(board)
if (winner) {
console.log('winner found: ' + winner)
return
}
@ -80,32 +104,58 @@ const buildTurnHandler = ({ gameName, dataName, checkWinner, textFromBoard, turn
}
winner = checkWinner(board)
const boardMessage = messageFromBoard({
const boardMessage = () => messageFromBoard({
dataName,
gameName,
textFromBoard,
board,
player1,
player2
player2,
channelMap
})
const winnerMessages = getMessages(winner)
await say(boardMessage + winnerMessages.you)
if (!winner) {
await say('Waiting for opponent\'s response...')
}
const removeEmoji = async emojiName =>
if (winner) {
await updateAll({ name: gameName, text: boardMessage() + '\nSomebody won! I do not yet know who!' })
const removeEmoji = emojiName => Object.values(channelMap).forEach(({ channel, ts }) =>
slack.app.client.reactions.remove({
channel: event.item.channel,
timestamp: message.messages[0]?.ts,
channel,
timestamp: ts,
name: emojiName
})
}))
turnChoiceEmojis.forEach(removeEmoji)
return
}
const winnerMessages = getMessages(winner)
// await say(boardMessage() + winnerMessages.you)
console.log('TurnHandler', { gameName, boardMessage: boardMessage() })
// await updateAll({ name: gameName, text: boardMessage() + '\nTurnHandler' })
// if (!winner) {
// await say('Waiting for opponent\'s response...')
// }
// const removeEmoji = async emojiName =>
// slack.app.client.reactions.remove({
// channel: event.item.channel,
// timestamp: message.messages[0]?.ts,
// name: emojiName
// })
// turnChoiceEmojis.forEach(removeEmoji)
console.log('SENDING to ' + opponent)
if (!channelMap[opponent]) {
const sentBoard = await slack.app.client.chat.postMessage({
channel: opponent,
text: boardMessage + winnerMessages.opponent
text: boardMessage() + winnerMessages.opponent
})
channelMap[opponent] = {
channel: sentBoard.channel,
ts: sentBoard.ts
}
console.log('BOARD MESSAGE AFTER ')
await updateAll({ name: gameName, text: boardMessage(), add: sentBoard })
} else {
await updateAll({ name: gameName, text: boardMessage() })
}
if (!winner) {
const sentBoard = channelMap[opponent]
await addChoiceEmojis({ ...sentBoard, choices: turnChoiceEmojis })
}
}

View File

@ -1,12 +1,12 @@
const routine = require("./routine");
const routine = require('./routine')
const emptyBoard = [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ']
const textFromBoard = board =>
` ${board[0]} | ${board[1]} | ${board[2]} \n` +
`-----------\n` +
'-----------\n' +
` ${board[3]} | ${board[4]} | ${board[5]} \n` +
`-----------\n` +
'-----------\n' +
` ${board[6]} | ${board[7]} | ${board[8]}`
const numEmojis = ['one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine']
@ -21,7 +21,7 @@ const winningThrees = [
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
[2, 4, 6]
]
const checkWinner = board => {
@ -71,4 +71,3 @@ routine.build({
makeMove: applyTurn,
checkWinner
})

View File

@ -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

View File

@ -26,12 +26,12 @@ onTempChangeRequested(change => {
case 'Hotter': {
lowTemp += 2
highTemp += 2
break;
break
}
case 'Colder': {
lowTemp -= 2
highTemp -= 2
break;
break
}
case 'Good': {
return
@ -42,9 +42,9 @@ onTempChangeRequested(change => {
lowTemp = cleanTemp(lowTemp)
const mode =
indoorTemperature < lowTemp ? heatMode : // Heat if lower than low
indoorTemperature > highTemp ? coolMode : // Cool if hotter than high
change === 'Hotter' ? heatMode : coolMode // Otherwise (lower priority) follow the requested change
indoorTemperature < lowTemp ? heatMode // Heat if lower than low
: indoorTemperature > highTemp ? coolMode // Cool if hotter than high
: change === 'Hotter' ? heatMode : coolMode // Otherwise (lower priority) follow the requested change
if (!mode) {
return

View File

@ -1,20 +1,22 @@
const { App: SlackApp } = require('@slack/bolt')
const config = require('../config')
const fs = require('fs')
const { addReactions, saveGame, setSlackAppClientChatUpdate, parseOr } = require('../games/hvacoins/utils')
const temperatureChannelId = 'C034156CE03'
const hvackerBotUserId = 'U0344TFA7HQ'
const sageUserId = 'U028BMEBWBV'
const dailyStandupChannelId = 'C03L533AU3Z'
const pollingMinutes = 5
const pollingPeriod = 1000 * 60 * pollingMinutes
const MAX_POLLS = 3
const HOURS_PER_WINDOW = 2
const colderEmoji = 'snowflake'
const hotterEmoji = 'fire'
const goodEmoji = '+1'
let app
try {
app = new SlackApp({
const app = new SlackApp({
token: config.slackBotToken,
signingSecret: config.slackSigningSecret,
appToken: config.slackAppToken,
@ -24,11 +26,8 @@ try {
// temperatureChannelId = fetched.channels.filter(channel => channel.name === 'thermo-posting')[0].id
// console.log('techThermostatChannelId', temperatureChannelId)
// })
} catch (e) {
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) => {
@ -41,7 +40,8 @@ const sendHelp = async (say, prefix) => {
await say({
text: prefix +
`Sending a message matching any of \`${pollTriggers.join('`, `')}\` will start a temperature poll.\n` +
'At this time I am not capable of actually changing the temperature. Go bug Quade.'
'\'Hotter\' and \'Colder\' votes offset. E.g. with votes Hotter - 4, Colder - 3, and Content - 2, the temp won\'t change.\n' +
`At this time I am not capable of actually changing the temperature. Go bug ${users.ThermoController}.`
})
}
@ -53,83 +53,120 @@ 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 })
}
})
const ourUsers = {
U028BMEBWBV: 'Sage',
U02U15RFK4Y: 'Adam',
U02AAB54V34: 'Houston',
U02KYLVK1GV: 'Quade',
U017PG4EL1Y: 'Max',
UTDLFGZA5: 'Tyler',
U017CB5L1K3: 'Andres'
const users = parseOr(fs.readFileSync('./users.json', 'utf-8'),
() => ({}))
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('SIGINT', true)
process.exit()
})
let pollHistory = []
const activePolls = {}
const testId = 'U028BMEBWBV_TEST'
let testMode = false
app.event('message', async ({ event, context, client, say }) => {
console.log(event)
if (event.user === sageUserId) {
if (event.subtype !== 'message_changed' && event?.text !== '!') {
console.log('message.event', {
...event,
userName: users[event.user]
})
}
if (event?.user === users.Admin) {
if (event?.text.startsWith('!')) {
if (testMode) {
await messageSage('Currently in test mode!')
await messageAdmin('Currently in test mode!')
}
}
if (event?.text === '!test') {
testMode = !testMode
await messageSage(`TestMode: ${testMode} with ID ${testId}`)
await messageAdmin(`TestMode: ${testMode} with ID ${testId}`)
} else if (event?.text === '!notest') {
testMode = false
await messageSage(`TestMode: ${testMode}`)
await messageAdmin(`TestMode: ${testMode}`)
}
if (testMode) {
event.user = testId
}
// console.log(event.blocks[0].elements[0])
}
for (const listener of messageListeners) {
listener({ event, say })
}
console.log('MSG', ourUsers[event.user], "'" + event.text + "'", new Date().toLocaleTimeString())
if (event.user === 'U028BMEBWBV' && event.channel === 'D0347Q4H9FE') {
if (event.user) {
console.log('MSG', users[event.user], "'" + event.text + "'", new Date().toLocaleTimeString())
}
if (event.user === users.Admin && event.channel === 'D0347Q4H9FE') {
if (event.text === '!!kill') {
process.exit()
saveGame('!!kill', true)
process.exit(1)
} else if (event.text === '!!restart') {
if (Object.entries(activePolls).length === 0) {
saveGame('!!restart', true)
process.exit(0)
} else {
await messageAdmin('Restart pending poll completion...')
pendingRestart = true
}
}
if (event.text?.startsWith('!say ') || event.text?.startsWith('!say\n')) {
await postToTechThermostatChannel(event.text.substring(4).trim())
return
}
if (event.text?.startsWith('!saytoq ')) {
await messageQuade(event.text.substring(7).trim())
return
}
if (event.text?.startsWith('!saytos')) {
await messageSage(event.text.substring(7).trim())
await postToTechThermostatChannel(event.text.substring(4).trim().replace('@here', '<!here>'))
return
}
}
const eventText = event.text?.toLowerCase() || ''
if (eventText.startsWith('!help')) {
if (eventText === '!help') {
await sendHelp(say)
return
}
if (!pollTriggers.includes(eventText)) {
if (!pollTriggers.includes(eventText) || event.user === users.John) {
if (halfTriggers.includes(eventText)) {
await sendHelp(say, 'It looks like you might want to change the temperature.')
}
return
}
if (event.channel !== temperatureChannelId) {
return say(`Please request polls in the appropriate channel.`)
}
if (activePolls[event.channel]) {
await postToTechThermostatChannel({ text: "There's already an active poll in this channel!" })
return
}
const now = new Date()
const windowStart = new Date()
windowStart.setHours(now.getHours() - HOURS_PER_WINDOW)
const pollsInWindow = pollHistory.filter(pollTime => pollTime > windowStart)
const pollText = MAX_POLLS === 1 ? 'poll' : 'polls'
const hourText = HOURS_PER_WINDOW === 1 ? 'hour' : 'hours'
if (pollsInWindow.length >= MAX_POLLS) {
await postToTechThermostatChannel({ text: `You have exceeded the limit of ${MAX_POLLS} ${pollText} per ${HOURS_PER_WINDOW} ${hourText}!` })
return
}
if (pollHistory.push(now) > MAX_POLLS) {
[, ...pollHistory] = pollHistory
}
activePolls[event.channel] = true
const pollTs = await startPoll()
@ -139,36 +176,73 @@ app.event('message', async ({ event, context, client, say }) => {
timestamp: pollTs,
full: true
})
const reactPosters = {}
reactions.message.reactions.forEach(r => r.users.forEach(user => {
reactPosters[user] ??= []
reactPosters[user].push(r.name)
}))
const reactCounts = {}
reactions.message.reactions.forEach(reaction => { reactCounts[reaction.name] = reaction.count })
Object.entries(reactPosters).forEach(([id, votes]) => {
console.log(`VOTES FROM ${id}:`, votes)
votes = votes.filter(v => [goodEmoji, hotterEmoji, colderEmoji].find(emoji => v.startsWith(emoji)))
if (votes.length === 1) {
const name = votes[0].replace(/:.*/g, '')
reactCounts[name] ??= 0
reactCounts[name] += 1
}
})
console.log('REACT COUNTS', JSON.stringify(reactCounts))
const contentVotes = reactCounts[goodEmoji]
const hotterVotes = reactCounts[hotterEmoji]
const colderVotes = reactCounts[colderEmoji]
const contentVotes = reactCounts[goodEmoji] || 0
let hotterVotes = reactCounts[hotterEmoji] || 0
let colderVotes = reactCounts[colderEmoji] || 0
console.log('before contentVotes', contentVotes)
console.log('before colderVotes', colderVotes)
console.log('before hotterVotes', hotterVotes)
let text = 'The people have spoken, and would like to '
if (hotterVotes > colderVotes) {
hotterVotes -= colderVotes
colderVotes = 0
} else if (colderVotes > hotterVotes) {
colderVotes -= hotterVotes
hotterVotes = 0
}
console.log('after contentVotes', contentVotes)
console.log('after colderVotes', colderVotes)
console.log('after hotterVotes', hotterVotes)
let text
if (hotterVotes > colderVotes && hotterVotes > contentVotes) {
text = `<@${users[users.ThermoController]}> The people have spoken, and would like to `
text += 'raise the temperature, quack.'
requestTempChange('Hotter')
} else if (colderVotes > hotterVotes && colderVotes > contentVotes) {
text = `<@${users[users.ThermoController]}> The people have spoken, and would like to `
text += 'lower the temperature, quack quack.'
requestTempChange('Colder')
} else {
text = `The people have spoken, and would like to `
text += 'keep the temperature as-is, quaaack.'
requestTempChange('Good')
}
await postToTechThermostatChannel({ text })
delete activePolls[event.channel]
if (pendingRestart && Object.entries(activePolls).length === 0) {
await messageAdmin('Performing pending restart!')
saveGame(null, true)
process.exit(0)
}
}, pollingPeriod)
})
let pendingRestart = false
;(async () => {
await app.start().catch(console.error)
await app.start()
console.log('Slack Bolt has started')
// setTimeout(async () => {
// await messageSage('<https://i.imgur.com/VCvfvdz.png|...>')
// }, 2000)
})()
const postToTechThermostatChannel = async optionsOrText => {
@ -180,8 +254,7 @@ const postToTechThermostatChannel = async optionsOrText => {
return app.client.chat.postMessage({ ...optionsOrText, channel: temperatureChannelId })
}
const messageSage = async optionsOrText => messageIn(sageUserId, optionsOrText)
const messageQuade = async optionsOrText => messageIn('U02KYLVK1GV', optionsOrText)
const messageAdmin = async optionsOrText => messageIn(users.Admin, optionsOrText)
const messageIn = async (channel, optionsOrText) => {
if (optionsOrText === null || typeof optionsOrText !== 'object') {
@ -194,19 +267,16 @@ const messageIn = async (channel, optionsOrText) => {
const startPoll = async () => {
const sent = await postToTechThermostatChannel({
text: `<!here|here> 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!)`
text: `<!here> Temperature poll requested! In ${pollingMinutes} minutes the temperature will be adjusted.
Pick :${colderEmoji}: if you want it colder, :${hotterEmoji}: if you want it hotter, or :${goodEmoji}: if you like it how it is.
(Note that I can't actually change the temperature yet. Make ${users.ThermoController} do it!)`
})
const addReaction = async emojiName =>
app.client.reactions.add({
channel: temperatureChannelId,
await addReactions({
app,
channelId: temperatureChannelId,
timestamp: sent.ts,
name: emojiName
reactions: [colderEmoji, hotterEmoji, goodEmoji]
})
await addReaction(colderEmoji)
await addReaction(hotterEmoji)
await addReaction(goodEmoji)
return sent.ts
}
@ -218,10 +288,12 @@ const requestTempChange = change => {
tempChangeListeners.forEach(listener => listener(change))
}
// noinspection HttpUrlsUsage
const encodeData = (key, data) =>
`<http://${key}ZZZ${Buffer.from(JSON.stringify(data), 'utf-8').toString('base64')}| >`
const decodeData = (key, message) => {
try {
const regex = new RegExp(`http://${key}ZZZ[^|]*`)
let match = message.match(regex)
if (!match) {
@ -229,25 +301,44 @@ const decodeData = (key, message) => {
}
match = match[0].substring(10 + key.length) // 10 === 'http://'.length + 'ZZZ'.length
return JSON.parse(Buffer.from(match, 'base64').toString('utf-8'))
} catch (e) {
console.error(e)
return null
}
}
const onReaction = listener => reactionListeners.push(listener)
const channelIsIm = async channel => (await app.client.conversations.info({ channel }))?.channel?.is_im
const wasMyMessage = async event => {
const text = (await app.client.conversations.history({
channel: event.item.channel,
latest: event.item.ts,
limit: 1,
inclusive: true
})).messages[0].text
const decoded = decodeData('commandPayload', text)
return decoded.event.user === event.user
}
onReaction(async ({ event }) => {
if (event.user === sageUserId && event.reaction === 'x') {
console.log(event)
console.log({ event })
if (event.reaction === 'x' && (event.user === users.Admin || (await wasMyMessage(event)) || await channelIsIm(event.item.channel))) {
try {
await app.client.chat.delete({channel: event.item.channel, ts: event.item.ts})
await app.client.chat.delete({ channel: event.item.channel, ts: event.item.ts })
} catch (e) {
console.error(e)
}
}
})
setSlackAppClientChatUpdate(app.client.chat.update)
module.exports = {
app,
hvackerBotUserId,
temperatureChannelId,
dailyStandupChannelId,
onAction: app.action,
getMessage,
updateMessage: app.client.chat.update,
@ -257,10 +348,12 @@ module.exports = {
onReaction,
encodeData,
decodeData,
sageUserId,
messageSage,
messageAdmin,
messageIn,
testMode,
testId,
ourUsers
users,
buildSayPrepend,
pollTriggers,
pendingRestart
}