2013-12-11 16:30:07 +00:00
|
|
|
moment = require('moment')
|
|
|
|
|
_ = require('lodash')
|
|
|
|
|
content = require('./content.coffee')
|
|
|
|
|
|
2013-12-13 22:55:47 +00:00
|
|
|
XP = 7.5 # originally 15, users were complaining that they gained exp too fast
|
2013-12-11 16:30:07 +00:00
|
|
|
HP = 2
|
|
|
|
|
|
|
|
|
|
api = module.exports = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
------------------------------------------------------
|
|
|
|
|
Time / Day
|
|
|
|
|
------------------------------------------------------
|
|
|
|
|
###
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
Each time we're performing date math (cron, task-due-days, etc), we need to take user preferences into consideration.
|
|
|
|
|
Specifically {dayStart} (custom day start) and {timezoneOffset}. This function sanitizes / defaults those values.
|
|
|
|
|
{now} is also passed in for various purposes, one example being the test scripts scripts testing different "now" times
|
|
|
|
|
###
|
|
|
|
|
sanitizeOptions = (o) ->
|
|
|
|
|
dayStart = if (!_.isNaN(+o.dayStart) and 0 <= +o.dayStart <= 24) then +o.dayStart else 0
|
|
|
|
|
timezoneOffset = if o.timezoneOffset then +(o.timezoneOffset) else +moment().zone()
|
|
|
|
|
now = if o.now then moment(o.now).zone(timezoneOffset) else moment(+new Date).zone(timezoneOffset)
|
|
|
|
|
# return a new object, we don't want to add "now" to user object
|
|
|
|
|
{dayStart, timezoneOffset, now}
|
|
|
|
|
|
|
|
|
|
api.startOfWeek = api.startOfWeek = (options={}) ->
|
|
|
|
|
o = sanitizeOptions(options)
|
|
|
|
|
moment(o.now).startOf('week')
|
|
|
|
|
|
|
|
|
|
api.startOfDay = (options={}) ->
|
|
|
|
|
o = sanitizeOptions(options)
|
|
|
|
|
moment(o.now).startOf('day').add('h', o.dayStart)
|
|
|
|
|
|
|
|
|
|
dayMapping = {0:'su',1:'m',2:'t',3:'w',4:'th',5:'f',6:'s'}
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
Absolute diff from "yesterday" till now
|
|
|
|
|
###
|
|
|
|
|
api.daysSince = (yesterday, options = {}) ->
|
|
|
|
|
o = sanitizeOptions options
|
|
|
|
|
Math.abs api.startOfDay(_.defaults {now:yesterday}, o).diff(o.now, 'days')
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
Should the user do this taks on this date, given the task's repeat options and user.preferences.dayStart?
|
|
|
|
|
###
|
|
|
|
|
api.shouldDo = (day, repeat, options={}) ->
|
|
|
|
|
return false unless repeat
|
|
|
|
|
o = sanitizeOptions options
|
|
|
|
|
selected = repeat[dayMapping[api.startOfDay(_.defaults {now:day}, o).day()]]
|
|
|
|
|
return selected unless moment(day).zone(o.timezoneOffset).isSame(o.now,'d')
|
|
|
|
|
if options.dayStart <= o.now.hour() # we're past the dayStart mark, is it due today?
|
|
|
|
|
return selected
|
|
|
|
|
else # we're not past dayStart mark, check if it was due "yesterday"
|
|
|
|
|
yesterday = moment(o.now).subtract(1,'d').day() # have to wrap o.now so as not to modify original
|
|
|
|
|
return repeat[dayMapping[yesterday]] # FIXME is this correct?? Do I need to do any timezone calcaulation here?
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
------------------------------------------------------
|
|
|
|
|
Drop System
|
|
|
|
|
------------------------------------------------------
|
|
|
|
|
###
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
Get a random property from an object
|
|
|
|
|
http://stackoverflow.com/questions/2532218/pick-random-property-from-a-javascript-object
|
|
|
|
|
returns random property (the value)
|
|
|
|
|
###
|
|
|
|
|
randomVal = (obj) ->
|
|
|
|
|
result = undefined
|
|
|
|
|
count = 0
|
|
|
|
|
for key, val of obj
|
|
|
|
|
result = val if Math.random() < (1 / ++count)
|
|
|
|
|
result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
------------------------------------------------------
|
|
|
|
|
Scoring
|
|
|
|
|
------------------------------------------------------
|
|
|
|
|
###
|
|
|
|
|
|
2013-12-13 22:11:04 +00:00
|
|
|
api.tnl = (lvl) ->
|
|
|
|
|
if lvl >= 100 then 0
|
|
|
|
|
else Math.round(((Math.pow(lvl, 2) * 0.25) + (10 * lvl) + 139.75) / 10) * 10
|
|
|
|
|
# round to nearest 10?
|
2013-12-11 16:30:07 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
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 + api.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
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
------------------------------------------------------
|
|
|
|
|
Content
|
|
|
|
|
------------------------------------------------------
|
|
|
|
|
###
|
|
|
|
|
|
|
|
|
|
api.content = content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
------------------------------------------------------
|
|
|
|
|
Misc Helpers
|
|
|
|
|
------------------------------------------------------
|
|
|
|
|
###
|
|
|
|
|
|
|
|
|
|
api.uuid = ->
|
|
|
|
|
"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace /[xy]/g, (c) ->
|
|
|
|
|
r = Math.random() * 16 | 0
|
|
|
|
|
v = (if c is "x" then r else (r & 0x3 | 0x8))
|
|
|
|
|
v.toString 16
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
Even though Mongoose handles task defaults, we want to make sure defaults are set on the client-side before
|
|
|
|
|
sending up to the server for performance
|
|
|
|
|
###
|
|
|
|
|
api.taskDefaults = (task) ->
|
|
|
|
|
defaults =
|
|
|
|
|
id: api.uuid()
|
|
|
|
|
type: 'habit'
|
|
|
|
|
text: ''
|
|
|
|
|
notes: ''
|
2013-12-13 03:40:05 +00:00
|
|
|
priority: 1
|
2013-12-11 16:30:07 +00:00
|
|
|
challenge: {}
|
2013-12-14 00:50:09 +00:00
|
|
|
attribute: 'str'
|
2013-12-11 16:30:07 +00:00
|
|
|
_.defaults task, defaults
|
|
|
|
|
_.defaults(task, {up:true,down:true}) if task.type is 'habit'
|
|
|
|
|
_.defaults(task, {history: []}) if task.type in ['habit', 'daily']
|
|
|
|
|
_.defaults(task, {completed:false}) if task.type in ['daily', 'todo']
|
|
|
|
|
_.defaults(task, {streak:0, repeat: {su:1,m:1,t:1,w:1,th:1,f:1,s:1}}) if task.type is 'daily'
|
|
|
|
|
task._id = task.id # may need this for TaskSchema if we go back to using it, see http://goo.gl/a5irq4
|
|
|
|
|
task.value ?= if task.type is 'reward' then 10 else 0
|
|
|
|
|
task
|
|
|
|
|
|
|
|
|
|
api.percent = (x,y) ->
|
|
|
|
|
x=1 if x==0
|
|
|
|
|
Math.round(x/y*100)
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
Remove whitespace #FIXME are we using this anywwhere? Should we be?
|
|
|
|
|
###
|
|
|
|
|
api.removeWhitespace = (str) ->
|
|
|
|
|
return '' unless str
|
|
|
|
|
str.replace /\s/g, ''
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
Encode the download link for .ics iCal file
|
|
|
|
|
###
|
|
|
|
|
api.encodeiCalLink = (uid, apiToken) ->
|
|
|
|
|
loc = window?.location.host or process?.env?.BASE_URL or ''
|
|
|
|
|
encodeURIComponent "http://#{loc}/v1/users/#{uid}/calendar.ics?apiToken=#{apiToken}"
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
Gold amount from their money
|
|
|
|
|
###
|
|
|
|
|
api.gold = (num) ->
|
|
|
|
|
if num
|
|
|
|
|
return Math.floor num
|
|
|
|
|
else
|
|
|
|
|
return "0"
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
Silver amount from their money
|
|
|
|
|
###
|
|
|
|
|
api.silver = (num) ->
|
|
|
|
|
if num
|
|
|
|
|
("0" + Math.floor (num - Math.floor(num))*100).slice -2
|
|
|
|
|
else
|
|
|
|
|
return "00"
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
Task classes given everything about the class
|
|
|
|
|
###
|
|
|
|
|
api.taskClasses = (task, filters=[], dayStart=0, lastCron=+new Date, showCompleted=false, main=false) ->
|
|
|
|
|
return unless task
|
|
|
|
|
{type, completed, value, repeat} = task
|
|
|
|
|
|
|
|
|
|
# completed / remaining toggle
|
|
|
|
|
return 'hidden' if (type is 'todo') and (completed != showCompleted)
|
|
|
|
|
|
|
|
|
|
# Filters
|
|
|
|
|
if main # only show when on your own list
|
|
|
|
|
for filter, enabled of filters
|
|
|
|
|
if enabled and not task.tags?[filter]
|
|
|
|
|
# All the other classes don't matter
|
|
|
|
|
return 'hidden'
|
|
|
|
|
|
|
|
|
|
classes = type
|
|
|
|
|
|
|
|
|
|
# show as completed if completed (naturally) or not required for today
|
|
|
|
|
if type in ['todo', 'daily']
|
|
|
|
|
if completed or (type is 'daily' and !api.shouldDo(+new Date, task.repeat, {dayStart}))
|
|
|
|
|
classes += " completed"
|
|
|
|
|
else
|
|
|
|
|
classes += " uncompleted"
|
|
|
|
|
else if type is 'habit'
|
|
|
|
|
classes += ' habit-wide' if task.down and task.up
|
|
|
|
|
|
|
|
|
|
if value < -20
|
|
|
|
|
classes += ' color-worst'
|
|
|
|
|
else if value < -10
|
|
|
|
|
classes += ' color-worse'
|
|
|
|
|
else if value < -1
|
|
|
|
|
classes += ' color-bad'
|
|
|
|
|
else if value < 1
|
|
|
|
|
classes += ' color-neutral'
|
|
|
|
|
else if value < 5
|
|
|
|
|
classes += ' color-good'
|
|
|
|
|
else if value < 10
|
|
|
|
|
classes += ' color-better'
|
|
|
|
|
else
|
|
|
|
|
classes += ' color-best'
|
|
|
|
|
return classes
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
Friendly timestamp
|
|
|
|
|
###
|
|
|
|
|
api.friendlyTimestamp = (timestamp) -> moment(timestamp).format('MM/DD h:mm:ss a')
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
Does user have new chat messages?
|
|
|
|
|
###
|
|
|
|
|
api.newChatMessages = (messages, lastMessageSeen) ->
|
|
|
|
|
return false unless messages?.length > 0
|
|
|
|
|
messages?[0] and (messages[0].id != lastMessageSeen)
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
are any tags active?
|
|
|
|
|
###
|
|
|
|
|
api.noTags = (tags) -> _.isEmpty(tags) or _.isEmpty(_.filter( tags, (t) -> t ) )
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
Are there tags applied?
|
|
|
|
|
###
|
|
|
|
|
api.appliedTags = (userTags, taskTags) ->
|
|
|
|
|
arr = []
|
|
|
|
|
_.each userTags, (t) ->
|
|
|
|
|
return unless t?
|
|
|
|
|
arr.push(t.name) if taskTags?[t.id]
|
|
|
|
|
arr.join(', ')
|
|
|
|
|
|
|
|
|
|
api.countPets = (originalCount, pets) ->
|
|
|
|
|
count = if originalCount? then originalCount else _.size(pets)
|
|
|
|
|
for pet of content.specialPets
|
|
|
|
|
count-- if pets[pet]
|
|
|
|
|
count
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
------------------------------------------------------
|
|
|
|
|
User (prototype wrapper to give it ops, helper funcs, and virtuals
|
|
|
|
|
------------------------------------------------------
|
|
|
|
|
###
|
|
|
|
|
|
|
|
|
|
###
|
2013-12-12 05:20:11 +00:00
|
|
|
User is now wrapped (both on client and server), adding a few new properties:
|
|
|
|
|
* getters (_statsComputed, tasks, etc)
|
|
|
|
|
* user.fns, which is a bunch of helper functions
|
|
|
|
|
These were originally up above, but they make more sense belonging to the user object so we don't have to pass
|
|
|
|
|
the user object all over the place. In fact, we should pull in more functions such as cron(), updateStats(), etc.
|
|
|
|
|
* user.ops, which is super important:
|
|
|
|
|
|
|
|
|
|
If a function is inside user.ops, it has magical properties. If you call it on the client it updates the user object in
|
|
|
|
|
the browser and when it's done it POSTs to the server, calling `src/controllers/user.js#OP_NAME` (the exact same name
|
|
|
|
|
of the op function). The first argument `req` is `{query, body, params}`, it's what the express controller function
|
|
|
|
|
expects. Because of this, we don't construct op functions in standard way (function(arg1,arg2,arg3){}), but instead
|
|
|
|
|
the first arg is {query, body, params} and the second is a callback(err, results). `query` is the most commonly-used,
|
|
|
|
|
but body is useful for updates/creates (eg, tasks), and params are used in those cases too (eg, {op:'updateTask',{params:{id:task.id}, body:task}}})
|
|
|
|
|
This needs refinement: see http://stackoverflow.com/questions/4024271/rest-api-best-practices-where-to-put-parameters
|
|
|
|
|
|
|
|
|
|
If `src/controllers/user.js#OP_NAME` doesn't exist, it's automatically added. It runs the code in user.ops.OP_NAME
|
|
|
|
|
to update the user model server-side, then performs `user.save()`. You can see this in action for `user.ops.buy`. That
|
|
|
|
|
function doesn't exist on the server - so the client calls it, it updates user in the browser, auto-POSTs to server, server
|
|
|
|
|
handles it by calling `user.ops.buy` again (to update user on the server), and then saves. We can do this for
|
|
|
|
|
everything that doesn't need any code different from what's in user.ops.OP_NAME for special-handling server-side. If we
|
|
|
|
|
*do* need special handling, just add `src/controllers/user.js#OP_NAME` to override the user.ops.OP_NAME, and just be
|
|
|
|
|
sure to call user.ops.OP_NAME at some point within the overridden function.
|
|
|
|
|
|
|
|
|
|
TODO
|
|
|
|
|
* Allow different error handling - like "200 - not enough gold" (which shouldn't be treated as 500)
|
|
|
|
|
* Is this the best way to wrap the user object? I thought of using user.prototype, but user is an object not a Function.
|
|
|
|
|
user on the server is a Mongoose model, so we can use prototype - but to do it on the client, we'd probably have to
|
|
|
|
|
move to $resource for user
|
|
|
|
|
* Move to $resource!
|
2013-12-13 22:11:04 +00:00
|
|
|
* Migrate the remaining parts: Tags, party invitiations
|
2013-12-12 05:20:11 +00:00
|
|
|
|
|
|
|
|
BUGS
|
|
|
|
|
* Daily repeats don't have the days showing up (translations issue?)
|
2013-12-11 16:30:07 +00:00
|
|
|
###
|
|
|
|
|
api.wrap = (user) ->
|
|
|
|
|
return if user._wrapped
|
|
|
|
|
user._wrapped = true
|
|
|
|
|
|
2013-12-13 22:11:04 +00:00
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
# user.ops shared client/server operations
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
2013-12-11 16:30:07 +00:00
|
|
|
user.ops =
|
|
|
|
|
|
2013-12-13 00:00:42 +00:00
|
|
|
sleep: (req, cb) ->
|
2013-12-13 03:23:32 +00:00
|
|
|
user.preferences.sleep = !user.preferences.sleep
|
2013-12-13 00:00:42 +00:00
|
|
|
cb null, req
|
|
|
|
|
|
|
|
|
|
clearCompleted: (req, cb) ->
|
|
|
|
|
user.todos = _.where(user.todos, {completed: false})
|
|
|
|
|
cb null, req
|
|
|
|
|
|
2013-12-11 16:30:07 +00:00
|
|
|
update: (req, cb) ->
|
|
|
|
|
_.each req.body, (v,k) ->
|
2013-12-13 22:55:47 +00:00
|
|
|
user.fns.dotSet(k,v);true
|
2013-12-11 16:30:07 +00:00
|
|
|
cb? null, req
|
|
|
|
|
|
2013-12-13 00:32:02 +00:00
|
|
|
sortTask: (req, cb) ->
|
|
|
|
|
{id} = req.params
|
|
|
|
|
{to, from} = req.query
|
|
|
|
|
task = user.tasks[id]
|
|
|
|
|
return cb({code:404, message: "No task found."}) unless task
|
|
|
|
|
return cb('?to=__&from=__ are required') unless to? and from?
|
|
|
|
|
user["#{task.type}s"].splice to, 0, user["#{task.type}s"].splice(from, 1)[0]
|
|
|
|
|
cb null, req
|
|
|
|
|
|
2013-12-11 16:30:07 +00:00
|
|
|
updateTask: (req, cb) ->
|
|
|
|
|
return cb?("Task not found") unless req.params.id and user.tasks[req.params.id]
|
|
|
|
|
_.merge user.tasks[req.params.id], req.body
|
|
|
|
|
cb? null, req
|
|
|
|
|
|
|
|
|
|
deleteTask: (req, cb) ->
|
|
|
|
|
cb? null, req
|
|
|
|
|
|
|
|
|
|
addTask: (req, cb) ->
|
|
|
|
|
task = api.taskDefaults(req.body)
|
|
|
|
|
user["#{task.type}s"].unshift(task)
|
|
|
|
|
cb? null, req
|
|
|
|
|
task
|
|
|
|
|
|
2013-12-12 22:57:01 +00:00
|
|
|
feed: (req, cb) ->
|
|
|
|
|
[pet, food] = [req.query?.pet, content.food[req.query?.food]]
|
|
|
|
|
[egg, potion] = pet.split('-')
|
|
|
|
|
|
|
|
|
|
return cb("?food=__&pet=__ are both required in query, and must be valid") unless pet and food # TODO validation on pet string
|
|
|
|
|
return cb("Can't feed this pet.") if content.specialPets[pet]
|
|
|
|
|
return cb("You already have that mount") if user.items.mounts[pet] and (userPets[pet] >= 50 or food.name is 'Saddle')
|
|
|
|
|
|
|
|
|
|
userPets = user.items.pets
|
2013-12-13 00:38:19 +00:00
|
|
|
message = ''
|
2013-12-12 22:57:01 +00:00
|
|
|
evolve = ->
|
|
|
|
|
userPets[pet] = 0
|
|
|
|
|
user.items.mounts[pet] = true
|
|
|
|
|
user.items.currentPet = "" if pet is user.items.currentPet
|
2013-12-13 00:38:19 +00:00
|
|
|
message = "You have tamed #{egg}, let's go for a ride!"
|
2013-12-12 22:57:01 +00:00
|
|
|
|
|
|
|
|
if food.name is 'Saddle'
|
|
|
|
|
evolve()
|
|
|
|
|
else
|
|
|
|
|
if food.target is potion
|
|
|
|
|
userPets[pet] += 5
|
2013-12-13 00:38:19 +00:00
|
|
|
message = "#{egg} really likes the #{food.name}!"
|
2013-12-12 22:57:01 +00:00
|
|
|
else
|
|
|
|
|
userPets[pet] += 2
|
2013-12-13 00:38:19 +00:00
|
|
|
message = "#{egg} eats the #{food.name} but doesn't seem to enjoy it."
|
|
|
|
|
if userPets[pet] >= 50 and !user.items.mounts[pet]
|
|
|
|
|
evolve()
|
2013-12-12 22:57:01 +00:00
|
|
|
user.items.food[food.name]--
|
2013-12-13 00:38:19 +00:00
|
|
|
cb {code:200, message}, req
|
2013-12-12 22:57:01 +00:00
|
|
|
|
2013-12-11 16:30:07 +00:00
|
|
|
buy: (req, cb) ->
|
|
|
|
|
{key} = req.query
|
|
|
|
|
item = if key is 'potion' then content.potion else content.gear.flat[key]
|
|
|
|
|
return cb?({code:404, message:"Item '#{key} not found (see https://github.com/HabitRPG/habitrpg-shared/blob/develop/script/content.coffee)"}) unless item
|
|
|
|
|
return cb?({code:200, message:'Not enough gold.'}) if user.stats.gp < item.value
|
|
|
|
|
if item.key is 'potion'
|
|
|
|
|
user.stats.hp += 15
|
|
|
|
|
user.stats.hp = 50 if user.stats.hp > 50
|
|
|
|
|
else
|
|
|
|
|
user.items.gear.equipped[item.type] = item.key
|
|
|
|
|
user.items.gear.owned[item.key] = true;
|
|
|
|
|
if item.klass in ['warrior','wizard','healer','rogue']
|
|
|
|
|
if user.fns.getItem('weapon').last and user.fns.getItem('armor').last and user.fns.getItem('head').last and user.fns.getItem('shield').last
|
|
|
|
|
user.achievements.ultimateGear = true
|
|
|
|
|
user.stats.gp -= item.value
|
|
|
|
|
cb? null, req
|
|
|
|
|
|
|
|
|
|
sell: (req, cb) ->
|
|
|
|
|
{key, type} = req.query
|
|
|
|
|
return cb("?type must by in [eggs, hatchingPotions, food]") unless type in ['eggs','hatchingPotions', 'food']
|
|
|
|
|
return cb("?key not found for user.items.#{type}") unless user.items[type][key]
|
|
|
|
|
user.items[type][key]--
|
|
|
|
|
user.stats.gp += content[type][key].value
|
|
|
|
|
cb? null, req
|
|
|
|
|
|
|
|
|
|
equip: (req, cb) ->
|
|
|
|
|
[type, key] = [req.query.type || 'equipped', req.query.key]
|
|
|
|
|
switch type
|
|
|
|
|
when 'mount'
|
|
|
|
|
user.items.currentMount = if user.items.currentMount is key then '' else key
|
|
|
|
|
when 'pet'
|
|
|
|
|
user.items.currentPet = if user.items.currentPet is key then '' else key
|
|
|
|
|
when 'costume','equipped'
|
|
|
|
|
item = content.gear.flat[key]
|
|
|
|
|
if item.type is "shield"
|
|
|
|
|
weapon = content.gear.flat[user.items.gear[type].weapon]
|
|
|
|
|
return cb?(weapon.text + " is two-handed") if weapon?.twoHanded
|
|
|
|
|
user.items.gear[type][item.type] = item.key
|
|
|
|
|
user.items.gear[type].shield = "shield_base_0" if item.twoHanded
|
|
|
|
|
cb? null, req
|
|
|
|
|
|
|
|
|
|
revive: (req, cb) ->
|
|
|
|
|
# Reset stats
|
|
|
|
|
_.merge user.stats, {hp:50, exp:0, gp:0}
|
|
|
|
|
user.stats.lvl-- if user.stats.lvl > 1
|
|
|
|
|
|
|
|
|
|
# Lose a stat point
|
2013-12-12 22:57:01 +00:00
|
|
|
lostStat = randomVal _.reduce(['str','con','per','int'], ((m,k)->(m[k]=k if user.stats[v];m)), {})
|
2013-12-11 16:30:07 +00:00
|
|
|
user.stats[lostStat]-- if lostStat
|
|
|
|
|
|
|
|
|
|
# Lose a gear piece
|
|
|
|
|
# Can't use randomVal since we need k, not v
|
|
|
|
|
count = 0
|
|
|
|
|
for k,v of user.items.gear.owned
|
|
|
|
|
lostItem = k if Math.random() < (1 / ++count)
|
|
|
|
|
if item = content.gear.flat[lostItem]
|
|
|
|
|
delete user.items.gear.owned[lostItem]
|
|
|
|
|
user.items.gear.equipped[item.type] = "#{item.type}_base_0"
|
|
|
|
|
user.items.gear.costume[item.type] = "#{item.type}_base_0"
|
|
|
|
|
user.markModified? 'items.gear'
|
|
|
|
|
cb? null, req
|
|
|
|
|
|
|
|
|
|
hatch: (req, cb) ->
|
|
|
|
|
{egg, hatchingPotion} = req.query
|
|
|
|
|
return cb("Please specify query.egg & query.hatchingPotion") unless egg and hatchingPotion
|
|
|
|
|
return cb("You're missing either that egg or that potion") unless user.items.eggs[egg] > 0 and user.items.hatchingPotions[hatchingPotion] > 0
|
|
|
|
|
pet = "#{egg}-#{hatchingPotion}"
|
|
|
|
|
return cb("You already have that pet. Try hatching a different combination!") if user.items.pets[pet]
|
|
|
|
|
user.items.pets[pet] = 5
|
|
|
|
|
user.items.eggs[egg]--
|
|
|
|
|
user.items.hatchingPotions[hatchingPotion]--
|
2013-12-13 00:38:19 +00:00
|
|
|
cb? {code:200, message:"Your egg hatched! Visit your stable to equip your pet."}, req
|
2013-12-11 16:30:07 +00:00
|
|
|
|
|
|
|
|
unlock: (req, cb) ->
|
|
|
|
|
{path} = req.query
|
|
|
|
|
fullSet = ~path.indexOf(",")
|
|
|
|
|
cost = if fullSet then 1.25 else 0.5 # 5G per set, 2G per individual
|
|
|
|
|
return cb?({code:401, err: "Not enough gems"}) if user.balance < cost
|
|
|
|
|
if fullSet
|
|
|
|
|
_.each path.split(","), (p) ->
|
2013-12-13 22:55:47 +00:00
|
|
|
user.fns.dotSet("purchased." + p, true);true
|
2013-12-11 16:30:07 +00:00
|
|
|
else
|
|
|
|
|
if user.fns.dotGet("purchased." + path) is true
|
2013-12-14 05:38:54 +00:00
|
|
|
split = path.split('.');v=split.pop();k=split.join('.')
|
|
|
|
|
user.fns.dotSet("preferences.#{k}",v)
|
2013-12-11 16:30:07 +00:00
|
|
|
return cb? null, req
|
|
|
|
|
user.fns.dotSet "purchased." + path, true
|
|
|
|
|
user.balance -= cost
|
|
|
|
|
if user.markModified
|
|
|
|
|
user._v++
|
|
|
|
|
user.markModified? "purchased"
|
|
|
|
|
cb? null, req
|
|
|
|
|
|
|
|
|
|
changeClass: (req, cb) ->
|
|
|
|
|
klass = req.query?.class
|
|
|
|
|
if klass in ['warrior','rogue','wizard','healer']
|
|
|
|
|
user.stats.class = klass
|
|
|
|
|
user.flags.classSelected = true
|
|
|
|
|
# Clear their gear and equip their new class's gear (can still equip old gear from inventory)
|
|
|
|
|
# If they've rolled this class before, restore their progress
|
|
|
|
|
_.each ["weapon", "armor", "shield", "head"], (type) ->
|
|
|
|
|
foundKey = false
|
|
|
|
|
_.findLast user.items.gear.owned, (v, k) ->
|
|
|
|
|
return foundKey = k if ~k.indexOf(type + "_" + klass)
|
|
|
|
|
# restore progress from when they last rolled this class
|
|
|
|
|
# weapon_0 is significant, don't reset to base_0
|
|
|
|
|
# rogues start with an off-hand weapon
|
|
|
|
|
user.items.gear.equipped[type] =
|
|
|
|
|
if foundKey then foundKey
|
|
|
|
|
else if type is "weapon" then "weapon_#{klass}_0"
|
|
|
|
|
else if type is "shield" and klass is "rogue" then "shield_rogue_0"
|
|
|
|
|
else "#{type}_base_0" # naked for the rest!
|
|
|
|
|
|
|
|
|
|
# Grant them their new class's gear
|
|
|
|
|
user.items.gear.owned["#{type}_#{klass}_0"] = true if type is "weapon" or (type is "shield" and klass is "rogue")
|
2013-12-13 22:55:47 +00:00
|
|
|
true
|
2013-12-11 16:30:07 +00:00
|
|
|
else
|
|
|
|
|
# Null ?class value means "reset class"
|
|
|
|
|
_.merge user.stats, {str: 0, def: 0, per: 0, int: 0}
|
|
|
|
|
user.flags.classSelected = false
|
|
|
|
|
#'stats.points': this is handled on the server
|
|
|
|
|
cb? null, req
|
|
|
|
|
|
|
|
|
|
allocate: (req, cb) ->
|
|
|
|
|
stat = req.query.stat or 'str'
|
|
|
|
|
if user.stats.points > 0
|
|
|
|
|
user.stats[stat]++
|
|
|
|
|
user.stats.points--
|
2013-12-13 02:28:29 +00:00
|
|
|
user.stats.mp++ if stat is 'int' #increase their MP along with their max MP
|
2013-12-11 16:30:07 +00:00
|
|
|
cb? null, req
|
|
|
|
|
|
|
|
|
|
score: (req, cb) ->
|
|
|
|
|
{id, direction} = req.params # up or down
|
|
|
|
|
task = user.tasks[id]
|
2013-12-13 22:55:47 +00:00
|
|
|
options = req.query or {}; _.defaults(options, {times:1, cron:false})
|
2013-12-11 16:30:07 +00:00
|
|
|
|
|
|
|
|
# This is for setting one-time temporary flags, such as streakBonus or itemDropped. Useful for notifying
|
|
|
|
|
# the API consumer, then cleared afterwards
|
|
|
|
|
user._tmp = {}
|
|
|
|
|
|
2013-12-13 22:55:47 +00:00
|
|
|
# TODO do we need this fail-safe casting anymore? Are we safe now we're off Derby?
|
|
|
|
|
stats = {gp: +user.stats.gp, hp: +user.stats.hp, exp: +user.stats.exp}
|
|
|
|
|
task.value = +task.value; task.streak = ~~task.streak; task.priority ?= 1
|
2013-12-11 16:30:07 +00:00
|
|
|
|
|
|
|
|
# If they're trying to purhcase a too-expensive reward, don't allow them to do that.
|
2013-12-13 22:55:47 +00:00
|
|
|
if task.value > stats.gp and task.type is 'reward'
|
2013-12-11 16:30:07 +00:00
|
|
|
return cb('Not enough Gold');
|
|
|
|
|
|
|
|
|
|
delta = 0
|
2013-12-13 22:55:47 +00:00
|
|
|
|
|
|
|
|
calculateDelta = (adjustvalue=true) ->
|
2013-12-11 16:30:07 +00:00
|
|
|
# If multiple days have passed, multiply times days missed
|
2013-12-13 22:55:47 +00:00
|
|
|
_.times options.times, ->
|
|
|
|
|
# Each iteration calculate the nextDelta, which is then accumulated in the total delta.
|
|
|
|
|
# Calculates the next task.value based on direction
|
|
|
|
|
# Uses a capped inverse log y=.95^x, y>= -5
|
|
|
|
|
|
|
|
|
|
# Min/max on task redness
|
|
|
|
|
currVal =
|
|
|
|
|
if task.value < -47.27 then -47.27
|
|
|
|
|
else if task.value > 21.27 then 21.27
|
|
|
|
|
else task.value
|
|
|
|
|
nextDelta = Math.pow(0.9747, currVal) * (if direction is 'down' then -1 else 1)
|
|
|
|
|
if adjustvalue
|
|
|
|
|
task.value += nextDelta
|
|
|
|
|
# Factor in STR. Only for up-scoring, ignore up-onlies and rewards
|
|
|
|
|
if direction is 'up' and task.type != 'reward' and !(task.type is 'habit' and !task.down)
|
|
|
|
|
task.value += user._statsComputed.str * .25
|
2013-12-11 16:30:07 +00:00
|
|
|
delta += nextDelta
|
|
|
|
|
|
|
|
|
|
addPoints = ->
|
2013-12-13 22:55:47 +00:00
|
|
|
# Exp Modifier
|
|
|
|
|
intMod = 1 + (user._statsComputed.int / 100)
|
|
|
|
|
stats.exp += Math.round(delta * XP * intMod * task.priority)
|
|
|
|
|
|
|
|
|
|
# GP modifier
|
|
|
|
|
gpMod = delta * task.priority
|
|
|
|
|
gpMod *= (user._statsComputed.per *.3) # Factor in PER
|
|
|
|
|
stats.gp +=
|
|
|
|
|
if task.streak
|
|
|
|
|
streakBonus = task.streak / 100 + 1 # eg, 1-day streak is 1.1, 2-day is 1.2, etc
|
|
|
|
|
afterStreak = gpMod * streakBonus
|
|
|
|
|
user._tmp.streakBonus = afterStreak - gpMod if (gpMod > 0) # keep this on-hand for later, so we can notify streak-bonus
|
|
|
|
|
afterStreak
|
|
|
|
|
else gpMod
|
|
|
|
|
|
|
|
|
|
# HP modifier
|
2013-12-11 16:30:07 +00:00
|
|
|
subtractPoints = ->
|
2013-12-13 22:55:47 +00:00
|
|
|
conMod = 1 - (user._statsComputed.con / 100)
|
|
|
|
|
stats.hp += Math.round(task.value * HP * conMod * task.priority)
|
|
|
|
|
# round to 1dp
|
2013-12-11 16:30:07 +00:00
|
|
|
|
2013-12-13 22:55:47 +00:00
|
|
|
switch task.type
|
2013-12-11 16:30:07 +00:00
|
|
|
when 'habit'
|
|
|
|
|
calculateDelta()
|
|
|
|
|
# Add habit value to habit-history (if different)
|
|
|
|
|
if (delta > 0) then addPoints() else subtractPoints()
|
2013-12-13 22:55:47 +00:00
|
|
|
|
|
|
|
|
# History
|
2013-12-14 05:38:54 +00:00
|
|
|
th = (task.history ?= [])
|
|
|
|
|
if th[th.length-1] and moment(th[th.length-1].date).isSame(new Date, 'day')
|
|
|
|
|
th[th.length-1].value = task.value
|
2013-12-13 22:55:47 +00:00
|
|
|
else
|
2013-12-14 05:38:54 +00:00
|
|
|
th.push {date: +new Date, value: task.value}
|
2013-12-13 22:55:47 +00:00
|
|
|
user.markModified? "habits.#{_.findIndex(user.habits, {id:task.id})}.history"
|
2013-12-11 16:30:07 +00:00
|
|
|
|
|
|
|
|
when 'daily'
|
2013-12-13 22:55:47 +00:00
|
|
|
if options.cron
|
2013-12-11 16:30:07 +00:00
|
|
|
calculateDelta()
|
|
|
|
|
subtractPoints()
|
|
|
|
|
task.streak = 0
|
|
|
|
|
else
|
|
|
|
|
calculateDelta()
|
|
|
|
|
addPoints() # obviously for delta>0, but also a trick to undo accidental checkboxes
|
|
|
|
|
if direction is 'up'
|
2013-12-13 22:55:47 +00:00
|
|
|
task.streak = if task.streak then task.streak + 1 else 1
|
2013-12-11 16:30:07 +00:00
|
|
|
# Give a streak achievement when the streak is a multiple of 21
|
2013-12-13 22:55:47 +00:00
|
|
|
if (task.streak % 21) is 0
|
2013-12-11 16:30:07 +00:00
|
|
|
user.achievements.streak = if user.achievements.streak then user.achievements.streak + 1 else 1
|
|
|
|
|
else
|
|
|
|
|
# Remove a streak achievement if streak was a multiple of 21 and the daily was undone
|
2013-12-13 22:55:47 +00:00
|
|
|
if (task.streak % 21) is 0
|
2013-12-11 16:30:07 +00:00
|
|
|
user.achievements.streak = if user.achievements.streak then user.achievements.streak - 1 else 0
|
2013-12-13 22:55:47 +00:00
|
|
|
task.streak = if task.streak then task.streak - 1 else 0
|
2013-12-11 16:30:07 +00:00
|
|
|
|
|
|
|
|
when 'todo'
|
2013-12-13 22:55:47 +00:00
|
|
|
if options.cron
|
2013-12-11 16:30:07 +00:00
|
|
|
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
|
2013-12-13 22:55:47 +00:00
|
|
|
stats.gp -= Math.abs(task.value)
|
2013-12-11 16:30:07 +00:00
|
|
|
num = parseFloat(task.value).toFixed(2)
|
|
|
|
|
# if too expensive, reduce health & zero gp
|
2013-12-13 22:55:47 +00:00
|
|
|
if stats.gp < 0
|
2013-12-11 16:30:07 +00:00
|
|
|
# hp - gp difference
|
2013-12-13 22:55:47 +00:00
|
|
|
stats.hp += stats.gp
|
|
|
|
|
stats.gp = 0
|
2013-12-11 16:30:07 +00:00
|
|
|
|
2013-12-13 22:55:47 +00:00
|
|
|
user.fns.updateStats stats
|
2013-12-11 16:30:07 +00:00
|
|
|
|
|
|
|
|
# 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'
|
2013-12-13 22:55:47 +00:00
|
|
|
user.fns.randomDrop({task, delta, priority:task.priority, streak:task.streak}) if direction is 'up'
|
2013-12-11 16:30:07 +00:00
|
|
|
|
|
|
|
|
cb? null, req
|
|
|
|
|
return delta
|
|
|
|
|
|
2013-12-13 22:11:04 +00:00
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
# user.fns helpers
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
2013-12-11 16:30:07 +00:00
|
|
|
user.fns =
|
2013-12-13 22:55:47 +00:00
|
|
|
|
2013-12-11 16:30:07 +00:00
|
|
|
getItem: (type) ->
|
|
|
|
|
item = content.gear.flat[user.items.gear.equipped[type]]
|
|
|
|
|
return content.gear.flat["#{type}_base_0"] unless item
|
|
|
|
|
item
|
|
|
|
|
|
|
|
|
|
updateStore: ->
|
|
|
|
|
changes = []
|
|
|
|
|
_.each ['weapon', 'armor', 'shield', 'head'], (type) ->
|
|
|
|
|
found = _.find content.gear.tree[type][user.stats.class], (item) ->
|
|
|
|
|
!user.items.gear.owned[item.key]
|
|
|
|
|
changes.push(found) if found
|
2013-12-13 22:55:47 +00:00
|
|
|
true
|
2013-12-11 16:30:07 +00:00
|
|
|
# Add special items (contrib gear, backer gear, etc)
|
|
|
|
|
_.defaults changes, _.transform _.where(content.gear.flat, {klass:'special'}), (m,v) ->
|
|
|
|
|
m.push v if v.canOwn?(user) && !user.items.gear.owned[v.key]
|
|
|
|
|
changes.push content.potion
|
|
|
|
|
# Return sorted store (array)
|
|
|
|
|
_.sortBy changes, (item) ->
|
|
|
|
|
switch item.type
|
|
|
|
|
when 'weapon' then 1
|
|
|
|
|
when 'armor' then 2
|
|
|
|
|
when 'head' then 3
|
|
|
|
|
when 'shield' then 4
|
|
|
|
|
when 'potion' then 5
|
|
|
|
|
else 6
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
This allows you to set object properties by dot-path. Eg, you can run pathSet('stats.hp',50,user) which is the same as
|
|
|
|
|
user.stats.hp = 50. This is useful because in our habitrpg-shared functions we're returning changesets as {path:value},
|
|
|
|
|
so that different consumers can implement setters their own way. Derby needs model.set(path, value) for example, where
|
|
|
|
|
Angular sets object properties directly - in which case, this function will be used.
|
|
|
|
|
###
|
|
|
|
|
dotSet: (path, val) ->
|
|
|
|
|
arr = path.split('.')
|
|
|
|
|
_.reduce arr, (curr, next, index) =>
|
|
|
|
|
if (arr.length - 1) == index
|
|
|
|
|
curr[next] = val
|
|
|
|
|
(curr[next] ?= {})
|
|
|
|
|
, user
|
|
|
|
|
|
|
|
|
|
dotGet: (path) ->
|
|
|
|
|
_.reduce path.split('.'), ((curr, next) => curr?[next]), user
|
|
|
|
|
|
2013-12-13 22:11:04 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
# Scoring
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
randomDrop: (modifiers) ->
|
|
|
|
|
{delta, priority, streak} = modifiers
|
|
|
|
|
streak ?= 0
|
|
|
|
|
# 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 = (api.daysSince(user.items.lastDrop.date, user.preferences) is 0) and (user.items.lastDrop.count >= 5)
|
|
|
|
|
return if reachedDropLimit
|
|
|
|
|
|
|
|
|
|
# % chance of getting a pet or meat
|
|
|
|
|
chanceMultiplier = Math.abs(delta)
|
|
|
|
|
chanceMultiplier *= priority # multiply chance by reddness
|
|
|
|
|
chanceMultiplier += streak # streak bonus
|
|
|
|
|
|
|
|
|
|
# Temporary solution to lower the maximum drop chance to 75 percent. More thorough
|
|
|
|
|
# overhaul of drop changes is needed. See HabitRPG/habitrpg#1922 for details.
|
|
|
|
|
# Old drop chance:
|
|
|
|
|
# if user.flags?.dropsEnabled and Math.random() < (.05 * chanceMultiplier)
|
|
|
|
|
max = 0.75 # Max probability of drop
|
|
|
|
|
a = 0.1 # rate of increase
|
|
|
|
|
alpha = a*max*chanceMultiplier/(a*chanceMultiplier+max) # current probability of drop
|
|
|
|
|
|
|
|
|
|
if user.flags?.dropsEnabled and Math.random() < alpha
|
|
|
|
|
# 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 = randomVal _.omit(content.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
|
|
|
|
|
else if rarity > .3
|
|
|
|
|
drop = randomVal content.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: 10% (of 30%)
|
|
|
|
|
if rarity < .03 then ['Golden']
|
|
|
|
|
# Rare: 20% (of 30%)
|
|
|
|
|
else if rarity < .09 then ['Zombie', 'CottonCandyPink', 'CottonCandyBlue']
|
|
|
|
|
# Uncommon: 30% (of 30%)
|
|
|
|
|
else if rarity < .18 then ['Red', 'Shade', 'Skeleton']
|
|
|
|
|
# Common: 40% (of 30%)
|
|
|
|
|
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 = randomVal _.pick(content.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++
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
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
|
|
|
|
|
###
|
2013-12-13 22:55:47 +00:00
|
|
|
updateStats: (stats) ->
|
|
|
|
|
# Game Over
|
|
|
|
|
return user.stats.hp=0 if stats.hp <= 0
|
|
|
|
|
|
|
|
|
|
user.stats.hp = stats.hp
|
|
|
|
|
user.stats.gp = if stats.gp >= 0 then stats.gp else 0
|
|
|
|
|
|
|
|
|
|
tnl = api.tnl(user.stats.lvl)
|
|
|
|
|
# if we're at level 100, turn xp to gold
|
|
|
|
|
if user.stats.lvl >= 100
|
|
|
|
|
stats.gp += stats.exp / 15
|
|
|
|
|
stats.exp = 0
|
|
|
|
|
user.stats.lvl = 100
|
|
|
|
|
else
|
|
|
|
|
# level up & carry-over exp
|
|
|
|
|
if stats.exp >= tnl
|
|
|
|
|
#silent = true # push through the negative xp silently
|
|
|
|
|
user.stats.exp = stats.exp # push normal + notification
|
|
|
|
|
while stats.exp >= tnl and user.stats.lvl < 100 # keep levelling up
|
|
|
|
|
stats.exp -= tnl
|
|
|
|
|
user.stats.lvl++
|
|
|
|
|
tnl = api.tnl(user.stats.lvl)
|
2013-12-14 00:50:09 +00:00
|
|
|
|
|
|
|
|
# Auto-allocate a point, or give them a new manual point
|
|
|
|
|
if user.preferences.automaticAllocation
|
|
|
|
|
tallies = _.reduce user.tasks, ((m,v)-> m[v.attribute or 'str'] += v.value;m), {str:0,int:0,con:0,per:0}
|
|
|
|
|
suggested = _.reduce tallies, ((m,v,k)-> if v>tallies[m] then k else m), 'str'
|
|
|
|
|
user.stats[suggested]++
|
|
|
|
|
else
|
|
|
|
|
# add new allocatable points. We could do user.stats.points++, but this does a fail-safe just in case
|
|
|
|
|
user.stats.points = user.stats.lvl - (user.stats.con + user.stats.str + user.stats.per + user.stats.int);
|
|
|
|
|
|
2013-12-13 22:55:47 +00:00
|
|
|
if user.stats.lvl == 100
|
|
|
|
|
stats.exp = 0
|
|
|
|
|
user.stats.hp = 50
|
|
|
|
|
user.stats.exp = stats.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 !user.flags.classSelected and user.stats.lvl >= 5
|
|
|
|
|
user.flags.classSelected
|
2013-12-13 22:11:04 +00:00
|
|
|
|
|
|
|
|
###
|
|
|
|
|
------------------------------------------------------
|
|
|
|
|
Cron
|
|
|
|
|
------------------------------------------------------
|
|
|
|
|
###
|
|
|
|
|
|
|
|
|
|
###
|
|
|
|
|
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}
|
|
|
|
|
###
|
|
|
|
|
cron: (options={}) ->
|
|
|
|
|
[now] = [+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 = api.daysSince user.lastCron, _.defaults({now}, user.preferences)
|
|
|
|
|
return unless daysMissed > 0
|
|
|
|
|
|
|
|
|
|
user.auth.timestamps.loggedin = new Date()
|
|
|
|
|
|
|
|
|
|
user.lastCron = now
|
|
|
|
|
|
|
|
|
|
# Reset the lastDrop count to zero
|
|
|
|
|
if user.items.lastDrop.count > 0
|
|
|
|
|
user.items.lastDrop.count = 0
|
|
|
|
|
|
|
|
|
|
# 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.preferences.sleep is true
|
|
|
|
|
|
|
|
|
|
# Tally each task
|
|
|
|
|
todoTally = 0
|
|
|
|
|
user.todos.concat(user.dailys).forEach (task) ->
|
|
|
|
|
return unless task
|
|
|
|
|
|
|
|
|
|
return if user.stats.buffs.stealth && user.stats.buffs.stealth-- # User "evades" a certain number of tasks
|
|
|
|
|
|
|
|
|
|
{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 api.shouldDo(thatDay, repeat, user.preferences)
|
|
|
|
|
if scheduleMisses > 0
|
|
|
|
|
user.ops.score({params:{id:task.id, direction:'down'}, query:{times:scheduleMisses, cron:true}})
|
|
|
|
|
|
|
|
|
|
switch type
|
|
|
|
|
when 'daily'
|
|
|
|
|
(task.history ?= []).push({ date: +new Date, value: task.value })
|
|
|
|
|
task.completed = false
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 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 += api.tnl(lvl)
|
|
|
|
|
(user.history.exp ?= []).push { date: now, value: expTally }
|
|
|
|
|
user.fns.preenUserHistory()
|
2013-12-13 22:55:47 +00:00
|
|
|
user.markModified? 'history'
|
|
|
|
|
user.markModified? 'dailys' # covers dailys.*.history
|
2013-12-13 22:11:04 +00:00
|
|
|
user
|
|
|
|
|
|
|
|
|
|
# Registered users with some history
|
|
|
|
|
preenUserHistory: (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
|
2013-12-13 22:55:47 +00:00
|
|
|
#user.markModified? 'history'
|
|
|
|
|
#user.markModified? 'habits'
|
|
|
|
|
#user.markModified? 'dailys'
|
2013-12-13 22:11:04 +00:00
|
|
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
# Virtual Attributes
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
2013-12-11 16:30:07 +00:00
|
|
|
# Aggregate all intrinsic stats, buffs, weapon, & armor into computed stats
|
|
|
|
|
Object.defineProperty user, '_statsComputed',
|
|
|
|
|
get: ->
|
|
|
|
|
_.reduce(['per','con','str','int'], (m,stat) =>
|
|
|
|
|
m[stat] = _.reduce('stats stats.buffs items.gear.equipped.weapon items.gear.equipped.armor items.gear.equipped.head items.gear.equipped.shield'.split(' '), (m2,path) =>
|
2013-12-13 02:28:29 +00:00
|
|
|
val = user.fns.dotGet(path)
|
2013-12-11 16:30:07 +00:00
|
|
|
m2 +
|
2013-12-13 02:28:29 +00:00
|
|
|
if ~path.indexOf('items.gear')
|
|
|
|
|
# get the gear stat, and multiply it by 1.5 if it's class-gear
|
|
|
|
|
(+content.gear.flat[val]?[stat] or 0) * (if ~val?.indexOf(user.stats.class) then 1.5 else 1)
|
|
|
|
|
else
|
|
|
|
|
+val[stat] or 0
|
2013-12-13 22:55:47 +00:00
|
|
|
, 0)
|
|
|
|
|
m[stat] += (user.stats.lvl - 1) / 2
|
|
|
|
|
m
|
2013-12-11 16:30:07 +00:00
|
|
|
, {})
|
|
|
|
|
Object.defineProperty user, 'tasks',
|
|
|
|
|
get: ->
|
|
|
|
|
tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards)
|
|
|
|
|
_.object(_.pluck(tasks, "id"), tasks)
|
|
|
|
|
|