mirror of
https://github.com/sudoxnym/habitica.git
synced 2026-05-25 15:15:52 +00:00
attempting to detect if they've switched timezones. Also, don't need to sanitize user.lastCron in shared, do it in Mongoose
482 lines
18 KiB
CoffeeScript
482 lines
18 KiB
CoffeeScript
moment = require('moment')
|
|
_ = require('lodash')
|
|
helpers = require('./helpers.coffee')
|
|
items = require('./items.coffee')
|
|
{eggs, hatchingPotions} = items.items
|
|
|
|
XP = 15
|
|
HP = 2
|
|
obj = module.exports = {}
|
|
|
|
obj.revive = (user, options={})->
|
|
paths = options.paths || {}
|
|
|
|
# Reset stats
|
|
user.stats.hp = 50
|
|
user.stats.exp = 0
|
|
user.stats.gp = 0
|
|
user.stats.lvl-- if user.stats.lvl > 1
|
|
|
|
## Lose a random item
|
|
loseThisItem = false
|
|
owned = user.items
|
|
# unless they're already at 0-everything
|
|
if ~~owned.armor > 0 or ~~owned.head > 0 or ~~owned.shield > 0 or ~~owned.weapon > 0
|
|
# find a random item to lose
|
|
until loseThisItem
|
|
#candidate = {0:'items.armor', 1:'items.head', 2:'items.shield', 3:'items.weapon', 4:'stats.gp'}[Math.random()*5|0]
|
|
candidate = {0:'armor', 1:'head', 2:'shield', 3:'weapon'}[Math.random()*4|0]
|
|
loseThisItem = candidate if owned[candidate] > 0
|
|
user.items[loseThisItem] = 0
|
|
"stats.hp stats.exp stats.gp stats.lvl items.#{loseThisItem}".split(' ').forEach (path) ->
|
|
paths[path] = 1
|
|
|
|
|
|
obj.priorityValue = (priority = '!') ->
|
|
switch priority
|
|
when '!' then 1
|
|
when '!!' then 1.5
|
|
when '!!!' then 2
|
|
else 1
|
|
|
|
obj.tnl = (level) ->
|
|
if level >= 100
|
|
value = 0
|
|
else
|
|
value = Math.round(((Math.pow(level, 2) * 0.25) + (10 * level) + 139.75) / 10) * 10
|
|
# round to nearest 10
|
|
return value
|
|
|
|
###
|
|
Calculates Exp modificaiton based on level and weapon strength
|
|
{value} task.value for exp gain
|
|
{weaponStrength) weapon strength
|
|
{level} current user level
|
|
{priority} user-defined priority multiplier
|
|
###
|
|
obj.expModifier = (value, weaponStr, level, priority = '!') ->
|
|
str = (level - 1) / 2
|
|
# ultimately get this from user
|
|
totalStr = (str + weaponStr) / 100
|
|
strMod = 1 + totalStr
|
|
exp = value * XP * strMod * obj.priorityValue(priority)
|
|
return Math.round(exp)
|
|
|
|
###
|
|
Calculates HP modification based on level and armor defence
|
|
{value} task.value for hp loss
|
|
{armorDefense} defense from armor
|
|
{helmDefense} defense from helm
|
|
{level} current user level
|
|
{priority} user-defined priority multiplier
|
|
###
|
|
obj.hpModifier = (value, armorDef, helmDef, shieldDef, level, priority = '!') ->
|
|
def = (level - 1) / 2
|
|
# ultimately get this from user?
|
|
totalDef = (def + armorDef + helmDef + shieldDef) / 100
|
|
#ultimate get this from user
|
|
defMod = 1 - totalDef
|
|
hp = value * HP * defMod * obj.priorityValue(priority)
|
|
return Math.round(hp * 10) / 10
|
|
# round to 1dp
|
|
|
|
###
|
|
Future use
|
|
{priority} user-defined priority multiplier
|
|
###
|
|
obj.gpModifier = (value, modifier, priority = '!', streak, user) ->
|
|
val = value * modifier * obj.priorityValue(priority)
|
|
if streak and user
|
|
streakBonus = streak / 100 + 1 # eg, 1-day streak is 1.1, 2-day is 1.2, etc
|
|
afterStreak = val * streakBonus
|
|
user._tmp.streakBonus = afterStreak - val if (val > 0) # keep this on-hand for later, so we can notify streak-bonus
|
|
return afterStreak
|
|
else
|
|
return val
|
|
|
|
###
|
|
Calculates the next task.value based on direction
|
|
Uses a capped inverse log y=.95^x, y>= -5
|
|
{currentValue} the current value of the task
|
|
{direction} up or down
|
|
###
|
|
obj.taskDeltaFormula = (currentValue, direction) ->
|
|
if currentValue < -47.27 then currentValue = -47.27
|
|
else if currentValue > 21.27 then currentValue = 21.27
|
|
delta = Math.pow(0.9747, currentValue)
|
|
return delta if direction is 'up'
|
|
return -delta
|
|
|
|
|
|
###
|
|
Drop System
|
|
###
|
|
|
|
randomDrop = (user, delta, priority, streak = 0, options={}) ->
|
|
paths = options.paths || {}
|
|
# limit drops to 2 / day
|
|
user.items.lastDrop ?=
|
|
date: +moment().subtract('d', 1) # trick - set it to yesterday on first run, that way they can get drops today
|
|
count: 0
|
|
|
|
reachedDropLimit = (helpers.daysSince(user.items.lastDrop.date, user.preferences) is 0) and (user.items.lastDrop.count >= 2)
|
|
return if reachedDropLimit
|
|
|
|
# % chance of getting a pet or meat
|
|
chanceMultiplier = Math.abs(delta)
|
|
chanceMultiplier *= obj.priorityValue(priority) # multiply chance by reddness
|
|
chanceMultiplier += streak # streak bonus
|
|
|
|
if user.flags?.dropsEnabled and Math.random() < (.05 * chanceMultiplier)
|
|
# current breakdown - 1% (adjustable) chance on drop
|
|
# If they got a drop: 50% chance of egg, 50% Hatching Potion. If hatchingPotion, broken down further even further
|
|
rarity = Math.random()
|
|
|
|
# # Food: 40% chance
|
|
# if rarity > .6
|
|
# drop = helpers.randomVal _.omit(items.items.food, 'Saddle')
|
|
# user.items.food[drop.name] ?= 0
|
|
# user.items.food[drop.name]+= 1
|
|
# drop.type = 'Food'
|
|
# drop.dialog = "You've found a #{drop.text} Food! #{drop.notes}"
|
|
|
|
# Eggs: 30% chance
|
|
if rarity > .5
|
|
drop = helpers.randomVal eggs
|
|
user.items.eggs[drop.name] ?= 0
|
|
user.items.eggs[drop.name]++
|
|
drop.type = 'Egg'
|
|
drop.dialog = "You've found a #{drop.text} Egg! #{drop.notes}"
|
|
|
|
# Hatching Potion, 30% chance - break down by rarity.
|
|
else
|
|
acceptableDrops =
|
|
# Very Rare: 4% of 50% (4% each)
|
|
if rarity < .02 then ['Golden']
|
|
# Rare: 22% of 50% (7.33% each)
|
|
else if rarity < .13 then ['Zombie', 'CottonCandyPink', 'CottonCandyBlue']
|
|
# Uncommon: 32% of 50% (10.66% each)
|
|
else if rarity < .29 then ['Red', 'Shade', 'Skeleton']
|
|
# Common: 42% of 50% (14% each)
|
|
else ['Base', 'White', 'Desert']
|
|
|
|
# No Rarity (@see https://github.com/HabitRPG/habitrpg/issues/1048, we may want to remove rareness when we add mounts)
|
|
#drop = helpers.randomVal hatchingPotions
|
|
drop = helpers.randomVal _.pick(hatchingPotions, ((v,k) -> k in acceptableDrops))
|
|
|
|
user.items.hatchingPotions[drop.name] ?= 0
|
|
user.items.hatchingPotions[drop.name]++
|
|
drop.type = 'HatchingPotion'
|
|
drop.dialog = "You've found a #{drop.text} Hatching Potion! #{drop.notes}"
|
|
|
|
# if they've dropped something, we want the consuming client to know so they can notify the user. See how the Derby
|
|
# app handles it for example. Would this be better handled as an emit() ?
|
|
user._tmp.drop = drop
|
|
|
|
user.items.lastDrop.date = +new Date
|
|
user.items.lastDrop.count++
|
|
|
|
|
|
# {task} task you want to score
|
|
# {direction} 'up' or 'down'
|
|
obj.score = (user, task, direction, options={}) ->
|
|
|
|
# This is for setting one-time temporary flags, such as streakBonus or itemDropped. Useful for notifying
|
|
# the API consumer, then cleared afterwards
|
|
user._tmp = {}
|
|
|
|
[gp, hp, exp, lvl] = [+user.stats.gp, +user.stats.hp, +user.stats.exp, ~~user.stats.lvl]
|
|
[type, value, streak, priority] = [task.type, +task.value, ~~task.streak, task.priority or '!']
|
|
[paths, times, cron] = [options.paths || {}, options.times || 1, options.cron || false]
|
|
|
|
# Handle corrupt tasks
|
|
# This type of cleanup-code shouldn't be necessary, revisit once we're off Derby
|
|
return 0 unless task.id
|
|
if !_.isNumber(value) or _.isNaN(value)
|
|
task.value = value = 0;
|
|
paths["tasks.#{task.id}.value"] = true
|
|
_.each user.stats, (v,k) ->
|
|
if !_.isNumber(v) or _.isNaN(v)
|
|
user.stats[k] = 0; paths["stats.#{k}"] = true
|
|
|
|
# If they're trying to purhcase a too-expensive reward, don't allow them to do that.
|
|
if task.value > user.stats.gp and task.type is 'reward'
|
|
return
|
|
|
|
delta = 0
|
|
calculateDelta = (adjustvalue = true) ->
|
|
# If multiple days have passed, multiply times days missed
|
|
_.times times, ->
|
|
# Each iteration calculate the delta (nextDelta), which is then accumulated in delta
|
|
# (aka, the total delta). This weirdness won't be necessary when calculating mathematically
|
|
# rather than iteratively
|
|
nextDelta = obj.taskDeltaFormula(value, direction)
|
|
value += nextDelta if adjustvalue
|
|
delta += nextDelta
|
|
|
|
addPoints = ->
|
|
weaponStr = items.getItem('weapon', user.items.weapon).strength
|
|
exp += obj.expModifier(delta, weaponStr, user.stats.lvl, priority) / 2 # /2 hack for now, people leveling too fast
|
|
if streak
|
|
gp += obj.gpModifier(delta, 1, priority, streak, user)
|
|
else
|
|
gp += obj.gpModifier(delta, 1, priority)
|
|
|
|
|
|
subtractPoints = ->
|
|
armorDef = items.getItem('armor', user.items.armor).defense
|
|
headDef = items.getItem('head', user.items.head).defense
|
|
shieldDef = items.getItem('shield', user.items.shield).defense
|
|
hp += obj.hpModifier(delta, armorDef, headDef, shieldDef, user.stats.lvl, priority)
|
|
|
|
switch type
|
|
when 'habit'
|
|
calculateDelta()
|
|
# Add habit value to habit-history (if different)
|
|
if (delta > 0) then addPoints() else subtractPoints()
|
|
if task.value != value
|
|
(task.history ?= []).push { date: +new Date, value: value }; paths["tasks.#{task.id}.history"] = true
|
|
|
|
when 'daily'
|
|
if cron
|
|
calculateDelta()
|
|
subtractPoints()
|
|
task.streak = 0
|
|
else
|
|
calculateDelta()
|
|
addPoints() # obviously for delta>0, but also a trick to undo accidental checkboxes
|
|
if direction is 'up'
|
|
streak = if streak then streak + 1 else 1
|
|
|
|
# Give a streak achievement when the streak is a multiple of 21
|
|
if (streak % 21) is 0
|
|
user.achievements.streak = if user.achievements.streak then user.achievements.streak + 1 else 1
|
|
paths["achievements.streak"] = true
|
|
|
|
else
|
|
# Remove a streak achievement if streak was a multiple of 21 and the daily was undone
|
|
if (streak % 21) is 0
|
|
user.achievements.streak = if user.achievements.streak then user.achievements.streak - 1 else 0
|
|
paths["achievements.streak"] = true
|
|
|
|
streak = if streak then streak - 1 else 0
|
|
task.streak = streak
|
|
|
|
paths["tasks.#{task.id}.streak"] = true
|
|
|
|
when 'todo'
|
|
if cron
|
|
calculateDelta()
|
|
#don't touch stats on cron
|
|
else
|
|
calculateDelta()
|
|
addPoints() # obviously for delta>0, but also a trick to undo accidental checkboxes
|
|
|
|
when 'reward'
|
|
# Don't adjust values for rewards
|
|
calculateDelta(false)
|
|
# purchase item
|
|
gp -= Math.abs(task.value)
|
|
num = parseFloat(task.value).toFixed(2)
|
|
# if too expensive, reduce health & zero gp
|
|
if gp < 0
|
|
hp += gp
|
|
# hp - gp difference
|
|
gp = 0
|
|
|
|
task.value = value; paths["tasks.#{task.id}.value"] = true
|
|
updateStats user, { hp, exp, gp }, {paths: paths}
|
|
|
|
# Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results)
|
|
if typeof window is 'undefined'
|
|
randomDrop(user, delta, priority, streak, {paths: paths}) if direction is 'up'
|
|
|
|
return delta
|
|
|
|
###
|
|
Updates user stats with new stats. Handles death, leveling up, etc
|
|
{stats} new stats
|
|
{update} if aggregated changes, pass in userObj as update. otherwise commits will be made immediately
|
|
###
|
|
updateStats = (user, newStats, options={}) ->
|
|
paths = options.paths || {}
|
|
# if user is dead, dont do anything
|
|
return if user.stats.hp <= 0
|
|
|
|
if newStats.hp?
|
|
# Game Over
|
|
if newStats.hp <= 0
|
|
user.stats.hp = 0
|
|
return
|
|
else
|
|
user.stats.hp = newStats.hp
|
|
|
|
if newStats.exp?
|
|
tnl = obj.tnl(user.stats.lvl)
|
|
#silent = false
|
|
# if we're at level 100, turn xp to gold
|
|
if user.stats.lvl >= 100
|
|
newStats.gp += newStats.exp / 15
|
|
newStats.exp = 0
|
|
user.stats.lvl = 100
|
|
else
|
|
# level up & carry-over exp
|
|
if newStats.exp >= tnl
|
|
#silent = true # push through the negative xp silently
|
|
user.stats.exp = newStats.exp # push normal + notification
|
|
while newStats.exp >= tnl and user.stats.lvl < 100 # keep levelling up
|
|
newStats.exp -= tnl
|
|
user.stats.lvl++
|
|
tnl = obj.tnl(user.stats.lvl)
|
|
if user.stats.lvl == 100
|
|
newStats.exp = 0
|
|
user.stats.hp = 50
|
|
|
|
user.stats.exp = newStats.exp
|
|
|
|
# Set flags when they unlock features
|
|
user.flags ?= {}
|
|
if !user.flags.customizationsNotification and (user.stats.exp > 10 or user.stats.lvl > 1)
|
|
user.flags.customizationsNotification = true
|
|
if !user.flags.itemsEnabled and user.stats.lvl >= 2
|
|
user.flags.itemsEnabled = true
|
|
if !user.flags.partyEnabled and user.stats.lvl >= 3
|
|
user.flags.partyEnabled = true
|
|
if !user.flags.dropsEnabled and user.stats.lvl >= 4
|
|
user.flags.dropsEnabled = true
|
|
user.items.eggs["Wolf"] = 1
|
|
|
|
if newStats.gp?
|
|
#FIXME what was I doing here? I can't remember, gp isn't defined
|
|
gp = 0.0 if (!gp? or gp < 0)
|
|
user.stats.gp = newStats.gp
|
|
|
|
###
|
|
At end of day, add value to all incomplete Daily & Todo tasks (further incentive)
|
|
For incomplete Dailys, deduct experience
|
|
Make sure to run this function once in a while as server will not take care of overnight calculations.
|
|
And you have to run it every time client connects.
|
|
{user}
|
|
###
|
|
obj.cron = (user, options={}) ->
|
|
[paths, now] = [options.paths || {}, +options.now || +new Date]
|
|
|
|
# They went to a different timezone
|
|
# FIXME:
|
|
# (1) This exit-early code isn't taking timezone into consideration!!
|
|
# (2) Won't switching timezones be handled automatically client-side anyway? (aka, can we just remove this code?)
|
|
# (3) And if not, is this the correct way to handle switching timezones
|
|
# if moment(user.lastCron).isAfter(now)
|
|
# user.lastCron = now
|
|
# return
|
|
|
|
daysMissed = helpers.daysSince user.lastCron, _.defaults({now}, user.preferences)
|
|
return unless daysMissed > 0
|
|
|
|
user.lastCron = now; paths['lastCron'] = true
|
|
|
|
# Reset the lastDrop count to zero
|
|
if user.items.lastDrop.count > 0
|
|
user.items.lastDrop.count = 0
|
|
paths['items.lastDrop'] = true
|
|
|
|
# User is resting at the inn. Used to be we un-checked each daily without performing calculation (see commits before fb29e35)
|
|
# but to prevent abusing the inn (http://goo.gl/GDb9x) we now do *not* calculate dailies, and simply set lastCron to today
|
|
return if user.flags.rest is true
|
|
|
|
# Tally each task
|
|
todoTally = 0
|
|
user.todos.concat(user.dailys).forEach (task) ->
|
|
return unless task
|
|
{id, type, completed, repeat} = task
|
|
# Deduct experience for missed Daily tasks, but not for Todos (just increase todo's value)
|
|
unless completed
|
|
scheduleMisses = daysMissed
|
|
# for dailys which have repeat dates, need to calculate how many they've missed according to their own schedule
|
|
if (type is 'daily') and repeat
|
|
scheduleMisses = 0
|
|
_.times daysMissed, (n) ->
|
|
thatDay = moment(now).subtract('days', n + 1)
|
|
scheduleMisses++ if helpers.shouldDo(thatDay, repeat, user.preferences)
|
|
obj.score(user, task, 'down', {times:scheduleMisses, cron:true, paths:paths}) if scheduleMisses > 0
|
|
|
|
switch type
|
|
when 'daily'
|
|
(task.history ?= []).push({ date: +new Date, value: task.value }); paths["tasks.#{task.id}.history"] = true
|
|
task.completed = false; paths["tasks.#{task.id}.completed"] = true;
|
|
when 'todo'
|
|
#get updated value
|
|
absVal = if (completed) then Math.abs(task.value) else task.value
|
|
todoTally += absVal
|
|
|
|
user.habits.forEach (task) -> # slowly reset 'onlies' value to 0
|
|
if task.up is false or task.down is false
|
|
if Math.abs(task.value) < 0.1
|
|
task.value = 0
|
|
else
|
|
task.value = task.value / 2
|
|
paths["tasks.#{task.id}.value"] = true
|
|
|
|
|
|
# Finished tallying
|
|
((user.history ?= {}).todos ?= []).push { date: now, value: todoTally }
|
|
# tally experience
|
|
expTally = user.stats.exp
|
|
lvl = 0 #iterator
|
|
while lvl < (user.stats.lvl - 1)
|
|
lvl++
|
|
expTally += obj.tnl(lvl)
|
|
(user.history.exp ?= []).push { date: now, value: expTally }
|
|
paths["history"] = true
|
|
obj.preenUserHistory(user) # we can probably start removing paths[...] stuff, no longer used by our app
|
|
user
|
|
|
|
###
|
|
Preen history for users with > 7 history entries
|
|
This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array
|
|
of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week
|
|
of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite]
|
|
###
|
|
preenHistory = (history) ->
|
|
history = _.filter(history, (h) -> !!h) # discard nulls (corrupted somehow)
|
|
newHistory = []
|
|
preen = (amount, groupBy) ->
|
|
groups = _.chain(history)
|
|
.groupBy((h) -> moment(h.date).format groupBy) # get date groupings to average against
|
|
.sortBy((h, k) -> k) # sort by date
|
|
.value() # turn into an array
|
|
groups = groups.slice(-amount)
|
|
groups.pop() # get rid of "this week", "this month", etc (except for case of days)
|
|
_.each groups, (group) ->
|
|
newHistory.push
|
|
date: moment(group[0].date).toDate()
|
|
#date: moment(group[0].date).format('MM/DD/YYYY') # Use this one when testing
|
|
value: _.reduce(group, ((m, obj) -> m + obj.value), 0) / group.length # average
|
|
true
|
|
|
|
# Keep the last:
|
|
preen 50, "YYYY" # 50 years (habit will toootally be around that long!)
|
|
preen moment().format('MM'), "YYYYMM" # last MM months (eg, if today is 05, keep the last 5 months)
|
|
|
|
# Then keep all days of this month. Note, the extra logic is to account for Habits, which can be counted multiple times per day
|
|
# FIXME I'd rather keep 1-entry/week of this month, then last 'd' days in this week. However, I'm having issues where the 1st starts mid week
|
|
thisMonth = moment().format('YYYYMM')
|
|
newHistory = newHistory.concat _.filter(history, (h)-> moment(h.date).format('YYYYMM') is thisMonth)
|
|
#preen Math.ceil(moment().format('D')/7), "YYYYww" # last __ weeks (# weeks so far this month)
|
|
#newHistory = newHistory.concat(history.slice -moment().format('D')) # each day of this week
|
|
|
|
newHistory
|
|
|
|
# Registered users with some history
|
|
obj.preenUserHistory = (user, minHistLen = 7) ->
|
|
_.each user.habits.concat(user.dailys), (task) ->
|
|
task.history = preenHistory(task.history) if task.history?.length > minHistLen
|
|
true
|
|
|
|
_.defaults user.history, {todos:[], exp: []}
|
|
user.history.exp = preenHistory(user.history.exp) if user.history.exp.length > minHistLen
|
|
user.history.todos = preenHistory(user.history.todos) if user.history.todos.length > minHistLen
|
|
#user.markModified('history')
|
|
#user.markModified('habits')
|
|
#user.markModified('dailys')
|
|
|