From 599e6a5540d35649d047723cf0a55f32c9af267c Mon Sep 17 00:00:00 2001 From: Ash Keel Date: Sun, 11 Jul 2021 15:34:39 +0200 Subject: [PATCH] Add cooldowns to rewards --- frontend/package-lock.json | 13 +++++ frontend/package.json | 1 + frontend/src/lib/time-utils.ts | 14 +++++ frontend/src/store/api/reducer.ts | 1 + frontend/src/ui/pages/loyalty/Rewards.tsx | 67 ++++++++++++++++++++-- frontend/src/ui/pages/loyalty/Settings.tsx | 11 +--- modules/loyalty/data.go | 1 + modules/loyalty/manager.go | 48 ++++++++++++---- modules/twitch/commands.go | 9 ++- 9 files changed, 139 insertions(+), 26 deletions(-) create mode 100644 frontend/src/lib/time-utils.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 60b866e..38cc196 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6524,6 +6524,11 @@ "error-ex": "^1.2.0" } }, + "parse-ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", + "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==" + }, "parse5": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.0.tgz", @@ -8370,6 +8375,14 @@ "fast-diff": "^1.1.2" } }, + "pretty-ms": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", + "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", + "requires": { + "parse-ms": "^2.1.0" + } + }, "process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9f32d80..58ecb7f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@types/react-dom": "^17.0.4", "bulma": "^0.9.2", "parcel": "^2.0.0-beta.2", + "pretty-ms": "^7.0.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-redux": "^7.2.4", diff --git a/frontend/src/lib/time-utils.ts b/frontend/src/lib/time-utils.ts new file mode 100644 index 0000000..b474643 --- /dev/null +++ b/frontend/src/lib/time-utils.ts @@ -0,0 +1,14 @@ +export function getInterval(duration: number): [number, number] { + if (duration < 60) { + return [duration, 1]; + } + if (duration % 3600 === 0) { + return [duration / 3600, 3600]; + } + if (duration % 60 === 0) { + return [duration / 60, 60]; + } + return [duration, 1]; +} + +export default { getInterval }; diff --git a/frontend/src/store/api/reducer.ts b/frontend/src/store/api/reducer.ts index e980fee..835fd34 100644 --- a/frontend/src/store/api/reducer.ts +++ b/frontend/src/store/api/reducer.ts @@ -82,6 +82,7 @@ export interface LoyaltyReward { image: string; price: number; required_info?: string; + cooldown: number; } export interface LoyaltyGoal { diff --git a/frontend/src/ui/pages/loyalty/Rewards.tsx b/frontend/src/ui/pages/loyalty/Rewards.tsx index 855d0e5..97eb3e1 100644 --- a/frontend/src/ui/pages/loyalty/Rewards.tsx +++ b/frontend/src/ui/pages/loyalty/Rewards.tsx @@ -1,6 +1,7 @@ import { RouteComponentProps } from '@reach/router'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; +import prettyTime from 'pretty-ms'; import { useModule } from '../../../lib/react-utils'; import { RootState } from '../../../store'; import { @@ -9,6 +10,7 @@ import { modules, } from '../../../store/api/reducer'; import Modal from '../../components/Modal'; +import { getInterval } from '../../../lib/time-utils'; interface RewardItemProps { item: LoyaltyReward; @@ -71,6 +73,11 @@ function RewardItem({ {expanded ? (
{item.description} + {item.cooldown > 0 ? ( +
+ Cooldown: {prettyTime(item.cooldown * 1000)} +
+ ) : null} {item.required_info ? (
Required info: {item.required_info} @@ -126,12 +133,19 @@ function RewardModal({ initialData?.description ?? '', ); const [price, setPrice] = useState(initialData?.price ?? 0); - const [extraRequired, setExtraRequired] = useState( - initialData?.required_info !== null, - ); const [extraDetails, setExtraDetails] = useState( initialData?.required_info ?? '', ); + const [extraRequired, setExtraRequired] = useState(extraDetails !== ''); + const [cooldown, setCooldown] = useState(initialData?.cooldown ?? 0); + + const [cooldownNum, cooldownMultiplier] = getInterval(cooldown); + const [tempCooldownNum, setTempCooldownNum] = useState(cooldownNum); + const [tempCooldownMult, setTempCooldownMult] = useState(cooldownMultiplier); + + useEffect(() => { + setCooldown(tempCooldownNum * tempCooldownMult); + }, [tempCooldownNum, tempCooldownMult]); const setIDex = (newID) => setID(newID.toLowerCase().replace(/[^a-zA-Z0-9]/gi, '-')); @@ -152,6 +166,7 @@ function RewardModal({ enabled: initialData?.enabled ?? false, image, required_info: extraRequired ? extraDetails : undefined, + cooldown, }); } }; @@ -311,6 +326,50 @@ function RewardModal({
) : null} +
+
+ +
+
+
+

+ { + const intNum = parseInt(ev.target.value, 10); + if (Number.isNaN(intNum)) { + return; + } + setTempCooldownNum(intNum); + }} + /> +

+

+ + + +

+
+
+
); } diff --git a/frontend/src/ui/pages/loyalty/Settings.tsx b/frontend/src/ui/pages/loyalty/Settings.tsx index 4666015..45aa531 100644 --- a/frontend/src/ui/pages/loyalty/Settings.tsx +++ b/frontend/src/ui/pages/loyalty/Settings.tsx @@ -2,18 +2,9 @@ import { RouteComponentProps } from '@reach/router'; import React, { useState, useEffect } from 'react'; import { useDispatch } from 'react-redux'; import { useModule } from '../../../lib/react-utils'; +import { getInterval } from '../../../lib/time-utils'; import apiReducer, { modules } from '../../../store/api/reducer'; -function getInterval(duration: number): [number, number] { - if (duration % 3600 === 0) { - return [duration / 3600, 3600]; - } - if (duration % 60 === 0) { - return [duration / 60, 60]; - } - return [duration, 1]; -} - export default function LoyaltySettingPage( // eslint-disable-next-line @typescript-eslint/no-unused-vars props: RouteComponentProps, diff --git a/modules/loyalty/data.go b/modules/loyalty/data.go index f98d912..97adb94 100644 --- a/modules/loyalty/data.go +++ b/modules/loyalty/data.go @@ -30,6 +30,7 @@ type Reward struct { Image string `json:"image"` Price int64 `json:"price"` CustomRequest string `json:"required_info,omitempty"` + Cooldown int64 `json:"cooldown"` } type Goal struct { diff --git a/modules/loyalty/manager.go b/modules/loyalty/manager.go index c8bbdf5..8ae3c83 100644 --- a/modules/loyalty/manager.go +++ b/modules/loyalty/manager.go @@ -16,19 +16,21 @@ import ( ) var ( + ErrRedeemInCooldown = errors.New("redeem is on cooldown") ErrGoalNotFound = errors.New("goal not found") ErrGoalAlreadyReached = errors.New("goal already reached") ) type Manager struct { - points map[string]PointsEntry - config Config - rewards RewardStorage - goals GoalStorage - queue RedeemQueueStorage - mu sync.Mutex - db *database.DB - logger logrus.FieldLogger + points map[string]PointsEntry + config Config + rewards RewardStorage + goals GoalStorage + queue RedeemQueueStorage + mu sync.Mutex + db *database.DB + logger logrus.FieldLogger + cooldowns map[string]time.Time } func NewManager(db *database.DB, log logrus.FieldLogger) (*Manager, error) { @@ -44,9 +46,10 @@ func NewManager(db *database.DB, log logrus.FieldLogger) (*Manager, error) { } manager := &Manager{ - logger: log, - db: db, - mu: sync.Mutex{}, + logger: log, + db: db, + mu: sync.Mutex{}, + cooldowns: make(map[string]time.Time), } // Ger data from DB if err := db.GetJSON(ConfigKey, &manager.config); err != nil { @@ -255,6 +258,19 @@ func (m *Manager) saveQueue() error { return m.db.PutJSON(QueueKey, m.queue) } +func (m *Manager) GetRewardCooldown(rewardID string) time.Time { + m.mu.Lock() + defer m.mu.Unlock() + + cooldown, ok := m.cooldowns[rewardID] + if !ok { + // Return zero time for a reward with no cooldown + return time.Time{} + } + + return cooldown +} + func (m *Manager) AddRedeem(redeem Redeem) error { m.mu.Lock() defer m.mu.Unlock() @@ -267,11 +283,21 @@ func (m *Manager) AddRedeem(redeem Redeem) error { return err } + // Add cooldown if applicable + if redeem.Reward.Cooldown > 0 { + m.cooldowns[redeem.Reward.ID] = time.Now().Add(time.Second * time.Duration(redeem.Reward.Cooldown)) + } + // Save points return m.saveQueue() } func (m *Manager) PerformRedeem(redeem Redeem) error { + // Check cooldown + if time.Now().Before(m.GetRewardCooldown(redeem.Reward.ID)) { + return ErrRedeemInCooldown + } + // Add redeem err := m.AddRedeem(redeem) if err != nil { diff --git a/modules/twitch/commands.go b/modules/twitch/commands.go index 705a6c0..b71d246 100644 --- a/modules/twitch/commands.go +++ b/modules/twitch/commands.go @@ -102,7 +102,14 @@ func cmdRedeemReward(bot *Bot, message irc.PrivateMessage) { Reward: reward, RequestText: text, }); err != nil { - bot.logger.WithError(err).Error("error while performing redeem") + switch err { + case loyalty.ErrRedeemInCooldown: + nextAvailable := bot.Loyalty.GetRewardCooldown(reward.ID) + bot.Client.Say(message.Channel, fmt.Sprintf("%s: That reward is in cooldown (available in %s)", message.User.DisplayName, + time.Until(nextAvailable).Truncate(time.Second))) + default: + bot.logger.WithError(err).Error("error while performing redeem") + } return }