2013-12-11 16:30:07 +00:00
moment = require ( ' moment ' )
_ = require ( ' lodash ' )
content = require ( ' ./content.coffee ' )
api = module.exports = { }
2013-12-28 18:31:21 +00:00
# little helper for large arrays of strings. %w"this that another" equivalent from Rails, I really miss that function
$w = (s)-> s . split ( ' ' )
2013-12-11 16:30:07 +00:00
# ##
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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 )
2014-01-13 17:44:46 +00:00
moment ( o . now ) . startOf ( ' day ' ) . add ( ' h ' , o . dayStart )
2013-12-11 16:30:07 +00:00
2014-01-18 04:41:20 +00:00
api.dayMapping = { 0 : ' su ' , 1 : ' m ' , 2 : ' t ' , 3 : ' w ' , 4 : ' th ' , 5 : ' f ' , 6 : ' s ' }
2013-12-11 16:30:07 +00:00
# ##
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
2014-01-18 04:41:20 +00:00
selected = repeat [ api . dayMapping [ api . startOfDay ( _ . defaults { now : day } , o ) . day ( ) ] ]
2013-12-11 16:30:07 +00:00
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
2014-01-18 04:41:20 +00:00
return repeat [ api . dayMapping [ yesterday ] ] # FIXME is this correct?? Do I need to do any timezone calcaulation here?
2013-12-11 16:30:07 +00:00
# ##
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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
2013-12-21 17:56:05 +00:00
# ##
A hyperbola function that creates diminishing returns , so you can ' t go to infinite (eg, with Exp gain).
{ max } The asymptote
{ bonus } All the numbers combined for your point bonus ( eg , task . value * user . stats . int * critChance , etc )
{ halfway } ( optional ) the point at which the graph starts bending
# ##
2013-12-29 23:58:45 +00:00
api.diminishingReturns = (bonus, max, halfway=max/2) ->
2013-12-21 17:56:05 +00:00
max * ( bonus / ( bonus + halfway ) )
2013-12-24 16:32:15 +00:00
api.monod = (bonus, rateOfIncrease, max) ->
rateOfIncrease * max * bonus / ( rateOfIncrease * bonus + max )
2013-12-21 17:56:05 +00:00
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
2013-12-22 02:08:58 +00:00
value: _ . reduce ( group , ( (m, obj) -> m + obj . value ) , 0 ) / group . length # average
2013-12-11 16:30:07 +00:00
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
2013-12-16 05:37:40 +00:00
# ##
Update the in - browser store with new gear . FIXME this was in user . fns , but it was causing strange issues there
# ##
api.updateStore = (user) ->
return unless user
changes = [ ]
2014-03-21 20:02:29 +00:00
_ . each [ ' weapon ' , ' armor ' , ' shield ' , ' head ' , ' back ' , ' headAccessory ' ] , (type) ->
2013-12-16 05:37:40 +00:00
found = _ . find content . gear . tree [ type ] [ user . stats . class ] , (item) ->
! user . items . gear . owned [ item . key ]
changes . push ( found ) if found
true
# Add special items (contrib gear, backer gear, etc)
2013-12-17 05:37:53 +00:00
changes = changes . concat _ . filter content . gear . flat , (v) ->
2014-02-23 04:41:48 +00:00
v . klass in [ ' special ' , ' mystery ' ] and ! user . items . gear . owned [ v . key ] and v . canOwn ? ( user )
2013-12-16 05:37:40 +00:00
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
2014-02-23 04:40:50 +00:00
when ' back ' then 5
2014-03-21 20:02:29 +00:00
when ' headAccessory ' then 6
when ' potion ' then 7
else 8
2013-12-16 05:37:40 +00:00
2013-12-11 16:30:07 +00:00
# ##
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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
2014-01-06 21:11:57 +00:00
api.countExists = (items)-> _ . reduce ( items , ( (m,v)-> m + ( if v then 1 else 0 ) ) , 0 )
2013-12-11 16:30:07 +00:00
# ##
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
# ##
2013-12-28 18:31:21 +00:00
api.taskDefaults = (task={}) ->
2013-12-25 17:56:21 +00:00
task.type = ' habit ' unless task . type and task . type in [ ' habit ' , ' daily ' , ' todo ' , ' reward ' ]
2013-12-11 16:30:07 +00:00
defaults =
id: api . uuid ( )
2013-12-25 17:56:21 +00:00
text: if task . id ? then task . id else ' '
2013-12-11 16:30:07 +00:00
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 '
2014-01-20 01:09:29 +00:00
dateCreated: new Date ( )
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
2013-12-17 08:06:20 +00:00
task.priority = 1 unless _ . isNumber ( task . priority ) # hotfix for apiv1. once we're off apiv1, we can remove this
2013-12-11 16:30:07 +00:00
task
2014-01-24 21:31:08 +00:00
api.percent = (x,y, dir) ->
switch dir
when " up " then roundFn = Math . ceil
when " down " then roundFn = Math . floor
else roundFn = Math . round
2013-12-11 16:30:07 +00:00
x = 1 if x == 0
2014-01-24 21:31:08 +00:00
roundFn ( x / y * 100 )
2013-12-11 16:30:07 +00:00
# ##
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
2014-01-03 21:42:18 +00:00
return ' hidden ' if ( type is ' todo ' and completed != showCompleted ) and main
2013-12-11 16:30:07 +00:00
# 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 ?
# ##
2013-12-15 20:23:27 +00:00
api.noTags = (tags) -> _ . isEmpty ( tags ) or _ . isEmpty ( _ . filter ( tags , (t)-> t ) )
2013-12-11 16:30:07 +00:00
# ##
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 )
2014-02-09 00:56:07 +00:00
for pet of content . questPets
count - - if pets [ pet ]
2013-12-11 16:30:07 +00:00
for pet of content . specialPets
count - - if pets [ pet ]
count
2014-02-09 00:56:07 +00:00
api.countMounts= (originalCount, mounts) ->
count = if originalCount ? then originalCount else _ . size ( mounts )
for mount of content . specialMounts
count - - if mounts [ mount ]
count
2013-12-11 16:30:07 +00:00
# ##
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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
2013-12-15 21:49:41 +00:00
the browser and when it ' s done it automatically 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 . This means we call our functions as if we were calling an Express route . Eg , instead of score ( task , direction ) ,
we call score ( { params : { id : task . id , direction : direction } } ) . This also forces us to think about our routes ( whether to use
params , query , or body for variables ) . see http : / / stackoverflow . com / questions / 4024271 / rest - api - best - practices - where - to - put - parameters
If ` src / controllers / user . js #OP_NAME` doesn't exist on the server, it's automatically added. It runs the code in user.ops.OP_NAME
2013-12-12 05:20:11 +00:00
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
2013-12-15 21:49:41 +00:00
everything that doesn ' t need any code difference 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 be
2013-12-12 05:20:11 +00:00
sure to call user . ops . OP_NAME at some point within the overridden function .
TODO
* 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-11 16:30:07 +00:00
# ##
2014-01-07 17:36:24 +00:00
api.wrap = (user, main=true) ->
2013-12-11 16:30:07 +00:00
return if user . _wrapped
user._wrapped = true
2013-12-13 22:11:04 +00:00
# ----------------------------------------------------------------------
# user.ops shared client/server operations
# ----------------------------------------------------------------------
2014-01-07 17:36:24 +00:00
if main
user.ops =
# ------
# User
# ------
update: (req, cb) ->
_ . each req . body , (v,k) ->
user . fns . dotSet ( k , v ) ; true
cb ? null , user
sleep: (req, cb) ->
user.preferences.sleep = ! user . preferences . sleep
cb ? null , { }
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
lostStat = user . fns . randomVal _ . reduce ( [ ' str ' , ' con ' , ' per ' , ' int ' ] , ( (m,k)-> m [ k ] = k if user . stats [ k ] ; m ) , { } )
user . stats [ lostStat ] - - if lostStat
# Lose a gear piece
# Note, they can actually lose item weapon_*_0 - it's 0 to buy back, no big deal
# Note ""+k string-casting. Without this, when run on the server Mongoose returns funny objects
lostItem = user . fns . randomVal _ . reduce ( user . items . gear . owned , ( (m,v,k)-> m [ ' ' + k ] = ' ' + k if v ; m ) , { } )
if item = content . gear . flat [ lostItem ]
user . items . gear . owned [ lostItem ] = false
user . items . gear . equipped [ item . type ] = " #{ item . type } _base_0 " if user . items . gear . equipped [ item . type ] is lostItem
user . items . gear . costume [ item . type ] = " #{ item . type } _base_0 " if user . items . gear . costume [ item . type ] is lostItem
user . markModified ? ' items.gear '
cb ? ( if item then { code : 200 , message : " Your #{ item . text } broke. " } else null ) , user
reset: (req, cb) ->
user.habits = [ ]
user.dailys = [ ]
user.todos = [ ]
user.rewards = [ ]
user.stats.hp = 50
user.stats.lvl = 1
user.stats.gp = 0
user.stats.exp = 0
# TODO handle MP
gear = user . items . gear
_ . each [ ' equipped ' , ' costume ' ] , (type) ->
gear [ type ] . armor = ' armor_base_0 '
gear [ type ] . weapon = ' weapon_base_0 '
gear [ type ] . head = ' head_base_0 '
gear [ type ] . shield = ' shield_base_0 '
user.items.gear.owned = { weapon_warrior_0 : true }
user . markModified ? ' items.gear.owned '
user.preferences.costume = false
cb ? null , user
2014-02-12 00:45:56 +00:00
reroll: (req, cb, ga) ->
2014-01-07 17:36:24 +00:00
if user . balance < 1
2014-01-31 05:02:35 +00:00
return cb ? { code : 401 , message: " Not enough gems. " }
2014-01-07 17:36:24 +00:00
user . balance - -
_ . each user . tasks , (task) ->
unless task . type is ' reward '
task.value = 0
user.stats.hp = 50
cb ? null , user
2014-02-12 00:45:56 +00:00
ga ? . event ( ' purchase ' , ' reroll ' ) . send ( )
2014-01-07 17:36:24 +00:00
2014-02-12 00:45:56 +00:00
rebirth: (req, cb, ga) ->
2014-01-07 17:36:24 +00:00
# Cost is 8 Gems ($2)
if user . balance < 2
2014-01-31 05:02:35 +00:00
return cb ? { code : 401 , message: " Not enough gems. " }
2014-01-07 17:36:24 +00:00
user . balance -= 2
# Save off user's level, for calculating achievement eligibility later
lvl = user . stats . lvl
# Turn tasks yellow, zero out streaks
_ . each user . tasks , (task) ->
unless task . type is ' reward '
task.value = 0
if task . type is ' daily '
task.streak = 0
# Reset all dynamic stats
stats = user . stats
stats.buffs = { }
stats.hp = 50
stats.lvl = 1
stats.class = ' warrior '
_ . each [ ' per ' , ' int ' , ' con ' , ' str ' , ' points ' , ' gp ' , ' exp ' , ' mp ' ] , (value) ->
stats [ value ] = 0
# Deequip character, set back to base armor and training sword
gear = user . items . gear
_ . each [ ' equipped ' , ' costume ' ] , (type) ->
gear [ type ] . armor = ' armor_base_0 '
gear [ type ] . weapon = ' weapon_warrior_0 '
gear [ type ] . head = ' head_base_0 '
gear [ type ] . shield = ' shield_base_0 '
if user . items . currentPet then user . ops . equip ( { params : { type: ' pet ' , key: user . items . currentPet } } )
if user . items . currentMount then user . ops . equip ( { params : { type: ' mount ' , key: user . items . currentMount } } )
2014-01-09 01:35:54 +00:00
# Strip owned gear down to the training sword, but preserve purchase history so user can re-purchase limited edition equipment
2014-01-09 01:55:46 +00:00
_ . each gear . owned , (v, k) -> if gear . owned [ k ] then gear . owned [ k ] = false ; true
2014-01-09 01:35:54 +00:00
gear.owned.weapon_warrior_0 = true
2014-01-07 17:36:24 +00:00
user . markModified ? ' items.gear.owned '
user.preferences.costume = false
# Remove unlocked features
flags = user . flags
if not ( user . achievements . ultimateGear or user . achievements . beastMaster )
flags.rebirthEnabled = false
flags.itemsEnabled = false
flags.dropsEnabled = false
flags.classSelected = false
# Award an achievement if this is their first Rebirth, or if they made it further than last time
if not ( user . achievements . rebirths )
user.achievements.rebirths = 1
user.achievements.rebirthLevel = lvl
else if ( lvl > user . achievements . rebirthLevel or lvl is 100 )
user . achievements . rebirths ++
user.achievements.rebirthLevel = lvl
cb ? null , user
2014-02-12 00:45:56 +00:00
ga ? . event ( ' purchase ' , ' Rebirth ' ) . send ( )
2014-01-07 17:36:24 +00:00
2014-01-28 14:55:42 +00:00
allocateNow: (req, cb) ->
_ . times user . stats . points , user . fns . autoAllocate
user.stats.points = 0
user . markModified ? ' stats '
cb ? null , user . stats
2014-01-07 17:36:24 +00:00
# ------
# Tasks
# ------
clearCompleted: (req, cb) ->
_ . remove user . todos , (t)-> t . completed and ! t . challenge ? . id
user . markModified ? ' todos '
cb ? null , user . todos
sortTask: (req, cb) ->
{ id } = req . params
{ to , from } = req . query
task = user . tasks [ id ]
return cb ? ( { code : 404 , message: " Task not found. " } ) unless task
return cb ? ( ' ?to=__&from=__ are required ' ) unless to ? and from ?
tasks = user [ " #{ task . type } s " ]
tasks . splice to , 0 , tasks . splice ( from , 1 ) [ 0 ]
cb ? null , tasks
updateTask: (req, cb) ->
2014-01-31 05:02:35 +00:00
return cb ? ( { code : 404 , message : " Task not found " } ) unless task = user . tasks [ req . params ? . id ]
2014-03-20 20:56:34 +00:00
_ . merge task , _ . omit ( req . body , [ ' checklist ' , ' id ' ] )
2014-01-07 17:36:24 +00:00
task.checklist = req . body . checklist if req . body . checklist
task . markModified ? ' tags '
cb ? null , task
deleteTask: (req, cb) ->
task = user . tasks [ req . params ? . id ]
return cb ? ( { code : 404 , message : ' Task not found ' } ) unless task
i = user [ task . type + " s " ] . indexOf ( task )
user [ task . type + " s " ] . splice ( i , 1 ) if ~ i
cb ? null , { }
addTask: (req, cb) ->
task = api . taskDefaults ( req . body )
user [ " #{ task . type } s " ] . unshift ( task )
2014-01-08 02:16:35 +00:00
if user . preferences . newTaskEdit then task._editing = true
2014-01-09 04:21:32 +00:00
if user . preferences . tagsCollapsed then task._tags = true
if user . preferences . advancedCollapsed then task._advanced = true
2014-01-07 17:36:24 +00:00
cb ? null , task
task
# ------
# Tags
# ------
addTag: (req, cb) ->
user . tags ? = [ ]
2014-01-08 00:23:22 +00:00
user . tags . push
name: req . body . name
id: req . body . id or api . uuid ( )
2014-01-07 17:36:24 +00:00
cb ? null , user . tags
updateTag: (req, cb) ->
tid = req . params . id
i = _ . findIndex user . tags , { id: tid }
2014-01-31 05:02:35 +00:00
return cb ? ( { code : 404 , message : ' Tag not found ' } ) if ! ~ i
2014-01-07 17:36:24 +00:00
user . tags [ i ] . name = req . body . name
cb ? null , user . tags [ i ]
deleteTag: (req, cb) ->
tid = req . params . id
i = _ . findIndex user . tags , { id: tid }
2014-01-31 05:02:35 +00:00
return cb ? ( { code : 404 , message : ' Tag not found ' } ) if ! ~ i
2014-01-07 17:36:24 +00:00
tag = user . tags [ i ]
delete user . filters [ tag . id ]
user . tags . splice i , 1
# remove tag from all tasks
_ . each user . tasks , (task) ->
delete task . tags [ tag . id ]
_ . each [ ' habits ' , ' dailys ' , ' todos ' , ' rewards ' ] , (type) ->
user . markModified ? type
cb ? null , user . tags
# ------
# Inventory
# ------
feed: (req, cb) ->
{ pet , food } = req . params
food = content . food [ food ]
[ egg , potion ] = pet . split ( ' - ' )
userPets = user . items . pets
return cb ? ( { code : 404 , message : " :pet not found in user.items.pets " } ) unless userPets [ pet ]
return cb ? ( { code : 404 , message : " :food not found in user.items.food " } ) unless user . items . food ? [ food . key ]
return cb ? ( { code : 401 , message : " Can ' t feed this pet. " } ) if content . specialPets [ pet ]
2014-01-26 01:58:18 +00:00
return cb ? ( { code : 401 , message : " You already have that mount. Try feeding another pet. " } ) if user . items . mounts [ pet ]
2014-01-07 17:36:24 +00:00
message = ' '
evolve = ->
2014-01-26 01:58:18 +00:00
userPets [ pet ] = - 1
# changed to -1 to mark "owned" pets
2014-01-07 17:36:24 +00:00
user . items . mounts [ pet ] = true
user.items.currentPet = " " if pet is user . items . currentPet
message = " You have tamed #{ egg } , let ' s go for a ride! "
if food . key is ' Saddle '
2013-12-13 00:38:19 +00:00
evolve ( )
2013-12-17 23:43:10 +00:00
else
2014-01-07 17:36:24 +00:00
if food . target is potion
userPets [ pet ] += 5
message = " #{ egg } really likes the #{ food . text } ! "
2013-12-13 22:55:47 +00:00
else
2014-01-07 17:36:24 +00:00
userPets [ pet ] += 2
message = " #{ egg } eats the #{ food . text } but doesn ' t seem to enjoy it. "
if userPets [ pet ] >= 50 and ! user . items . mounts [ pet ]
evolve ( )
user . items . food [ food . key ] - -
cb ? { code : 200 , message } , userPets [ pet ]
# buy is for gear, purchase is for gem-purchaseables (i know, I know...)
2014-02-12 00:45:56 +00:00
purchase: (req, cb, ga) ->
2014-01-07 17:36:24 +00:00
{ type , key } = req . params
return cb ? ( { code : 404 , message : " :type must be in [hatchingPotions,eggs,food,quests,special] " } , req ) unless type in [ ' eggs ' , ' hatchingPotions ' , ' food ' , ' quests ' , ' special ' ]
item = content [ type ] [ key ]
return cb ? ( { code : 404 , message : " :key not found for Content. #{ type } " } , req ) unless item
2014-01-29 14:55:02 +00:00
return cb ? ( { code : 401 , message : ' Not enough gems. ' } ) if user . balance < ( item . value / 4 )
2014-01-07 17:36:24 +00:00
user . items [ type ] [ key ] = 0 unless user . items [ type ] [ key ]
user . items [ type ] [ key ] ++
user . balance -= ( item . value / 4 )
cb ? null , _ . pick ( user , $w ' items balance ' )
2014-02-12 00:45:56 +00:00
ga ? . event ( ' purchase ' , key ) . send ( )
2014-01-07 17:36:24 +00:00
# buy is for gear, purchase is for gem-purchaseables (i know, I know...)
buy: (req, cb) ->
{ key } = req . params
2014-02-14 00:57:10 +00:00
2014-01-07 17:36:24 +00:00
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 : 401 , 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
message = user . fns . handleTwoHanded ( item )
message ? = " Bought #{ item . text } . "
2014-01-10 04:33:01 +00:00
if not user . achievements . ultimateGear and item . last
2014-01-24 06:02:51 +00:00
user . fns . ultimateGear ( )
2014-01-07 17:36:24 +00:00
user . stats . gp -= item . value
cb ? { code : 200 , message } , _ . pick ( user , $w ' items achievements stats ' )
sell: (req, cb) ->
{ key , type } = req . params
return cb ? ( { code : 404 , message : " :type not found. Must bes in [eggs, hatchingPotions, food] " } ) unless type in [ ' eggs ' , ' hatchingPotions ' , ' food ' ]
return cb ? ( { code : 404 , message : " :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 , _ . pick ( user , $w ' stats items ' )
equip: (req, cb) ->
[ type , key ] = [ req . params . type || ' equipped ' , req . params . 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 ]
user . items . gear [ type ] [ item . type ] = item . key
message = user . fns . handleTwoHanded ( item , type )
cb ? ( if message then { code : 200 , message } else null ) , user . items
hatch: (req, cb) ->
{ egg , hatchingPotion } = req . params
return cb ? ( { code : 404 , message : " Please specify query.egg & query.hatchingPotion " } ) unless egg and hatchingPotion
return cb ? ( { code : 401 , message : " You ' re missing either that egg or that potion " } ) unless user . items . eggs [ egg ] > 0 and user . items . hatchingPotions [ hatchingPotion ] > 0
pet = " #{ egg } - #{ hatchingPotion } "
2014-01-31 04:51:44 +00:00
return cb ? ( { code : 401 , message : " You already have that pet. Try hatching a different combination! " } ) if user . items . pets [ pet ] and user . items . pets [ pet ] > 0
2014-01-07 17:36:24 +00:00
user . items . pets [ pet ] = 5
user . items . eggs [ egg ] - -
user . items . hatchingPotions [ hatchingPotion ] - -
cb ? { code : 200 , message : " Your egg hatched! Visit your stable to equip your pet. " } , user . items
2014-02-12 00:45:56 +00:00
unlock: (req, cb, ga) ->
2014-01-07 17:36:24 +00:00
{ path } = req . query
fullSet = ~ path . indexOf ( " , " )
cost = if fullSet then 1.25 else 0.5 # 5G per set, 2G per individual
alreadyOwns = ! fullSet and user . fns . dotGet ( " purchased. " + path ) is true
return cb ? ( { code : 401 , message: " Not enough gems " } ) if user . balance < cost and ! alreadyOwns
if fullSet
_ . each path . split ( " , " ) , (p) ->
user . fns . dotSet ( " purchased. #{ p } " , true ) ; true
else
if alreadyOwns
split = path . split ( ' . ' ) ; v = split . pop ( ) ; k = split . join ( ' . ' )
user . fns . dotSet ( " preferences. #{ k } " , v )
return cb ? null , req
user . fns . dotSet " purchased. " + path , true
user . balance -= cost
user . markModified ? ' purchased '
cb ? null , _ . pick ( user , $w ' purchased preferences ' )
2014-02-12 00:45:56 +00:00
ga ? . event ( ' purchase ' , path ) . send ( )
2014-01-07 17:36:24 +00:00
# ------
# Classes
# ------
2014-02-12 00:45:56 +00:00
changeClass: (req, cb, ga) ->
2014-01-07 17:36:24 +00:00
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 ) and v is true
# 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 " )
true
else
# Null ?class value means "reset class"
if user . preferences . disableClasses
user.preferences.disableClasses = false
user.preferences.autoAllocate = false
2013-12-11 16:30:07 +00:00
else
2014-01-07 17:36:24 +00:00
return cb ? ( { code : 401 , message : " Not enough gems " } ) unless user . balance >= . 75
user . balance -= . 75
_ . merge user . stats , { str: 0 , con: 0 , per: 0 , int: 0 , points: user . stats . lvl }
user.flags.classSelected = false
2014-02-12 00:45:56 +00:00
ga ? . event ( ' purchase ' , ' changeClass ' ) . send ( )
2014-01-07 17:36:24 +00:00
#'stats.points': this is handled on the server
cb ? null , _ . pick ( user , $w ' stats flags items preferences ' )
disableClasses: (req, cb) ->
user.stats.class = ' warrior '
user.flags.classSelected = true
user.preferences.disableClasses = true
user.preferences.autoAllocate = true
user.stats.str = user . stats . lvl
user.stats.points = 0
cb ? null , _ . pick ( user , $w ' stats flags preferences ' )
allocate: (req, cb) ->
stat = req . query . stat or ' str '
if user . stats . points > 0
user . stats [ stat ] ++
user . stats . points - -
user . stats . mp ++ if stat is ' int ' #increase their MP along with their max MP
cb ? null , _ . pick ( user , $w ' stats ' )
2014-02-14 00:57:10 +00:00
readValentine: (req,cb) ->
user . items . special . valentineReceived . shift ( )
user . markModified ? ' items.special.valentineReceived '
cb ? null , ' items.special '
2014-01-07 17:36:24 +00:00
# ------
# Score
# ------
score: (req, cb) ->
{ id , direction } = req . params # up or down
task = user . tasks [ id ]
options = req . query or { } ; _ . defaults ( options , { times : 1 , cron : false } )
# This is for setting one-time temporary flags, such as streakBonus or itemDropped. Useful for notifying
# the API consumer, then cleared afterwards
user._tmp = { }
# 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
# If they're trying to purhcase a too-expensive reward, don't allow them to do that.
if task . value > stats . gp and task . type is ' reward '
2014-01-31 05:02:35 +00:00
return cb ? { code : 401 , message : ' Not enough Gold ' }
2014-01-07 17:36:24 +00:00
delta = 0
calculateDelta = ->
# If multiple days have passed, multiply times days missed
_ . 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 )
# Checklists
if task . checklist ? . length > 0
# If the Daily, only dock them them a portion based on their checklist completion
if direction is ' down ' and task . type is ' daily ' and options . cron
nextDelta *= ( 1 - _ . reduce ( task . checklist , ( (m,i)-> m + ( if i . completed then 1 else 0 ) ) , 0 ) / task . checklist . length )
2014-01-19 16:36:04 +00:00
# If To-Do, point-match the TD per checklist item completed
if task . type is ' todo ' and direction is ' up '
nextDelta *= ( 1 + _ . reduce ( task . checklist , ( (m,i)-> m + ( if i . completed then 1 else 0 ) ) , 0 ) )
2014-01-07 17:36:24 +00:00
unless task . type is ' reward '
2014-01-19 15:36:23 +00:00
if ( user . preferences . automaticAllocation is true and user . preferences . allocationMode is ' taskbased ' and ! ( task . type is ' todo ' and direction is ' down ' ) ) then user . stats . training [ task . attribute ] += nextDelta
2014-01-07 17:36:24 +00:00
# ===== STRENGTH =====
# (Only for up-scoring, ignore up-onlies and rewards)
# Note, we create a new val (adjustAmt) to add to task.value, since delta will be used in Exp & GP calculations - we don't want STR to bonus that
2014-02-28 03:21:04 +00:00
if direction is ' up ' and ! ( task . type is ' habit ' and ! task . down )
2014-01-07 17:36:24 +00:00
user.party.quest.progress.up = user . party . quest . progress . up || 0 ;
2014-02-28 03:21:04 +00:00
user . party . quest . progress . up += ( nextDelta * ( 1 + ( user . _statsComputed . str / 200 ) ) ) if task . type in [ ' daily ' , ' todo ' ]
task . value += nextDelta
2014-01-07 17:36:24 +00:00
delta += nextDelta
addPoints = ->
# ===== CRITICAL HITS =====
2014-02-15 22:21:32 +00:00
# allow critical hit only when checking off a task, not when unchecking it:
_crit = ( if delta > 0 then user . fns . crit ( ) else 1 )
2014-01-07 17:36:24 +00:00
# if there was a crit, alert the user via notification
user._tmp.crit = _crit if _crit > 1
# Exp Modifier
# ===== Intelligence =====
# TODO Increases Experience gain by .2% per point.
intBonus = 1 + ( user . _statsComputed . int * . 025 )
stats . exp += Math . round ( delta * intBonus * task . priority * _crit * 6 )
# GP modifier
# ===== PERCEPTION =====
# TODO Increases Gold gained from tasks by .3% per point.
perBonus = ( 1 + user . _statsComputed . per * . 02 )
gpMod = ( delta * task . priority * _crit * perBonus )
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
subtractPoints = ->
# ===== CONSTITUTION =====
# TODO Decreases HP loss from bad habits / missed dailies by 0.5% per point.
conBonus = 1 - ( user . _statsComputed . con / 250 )
conBonus = 0.1 if conBonus < . 1
hpMod = delta * conBonus * task . priority * 2 # constant 2 multiplier for better results
stats . hp += Math . round ( hpMod * 10 ) / 10 # round to 1dp
switch task . type
when ' habit '
2013-12-11 16:30:07 +00:00
calculateDelta ( )
2014-01-07 17:36:24 +00:00
# Add habit value to habit-history (if different)
if ( delta > 0 ) then addPoints ( ) else subtractPoints ( )
# History
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-11 16:30:07 +00:00
else
2014-01-07 17:36:24 +00:00
th . push { date: + new Date , value: task . value }
user . markModified ? " habits. #{ _ . findIndex ( user . habits , { id : task . id } )}.history "
2013-12-11 16:30:07 +00:00
2014-01-07 17:36:24 +00:00
when ' daily '
if options . cron
calculateDelta ( )
subtractPoints ( )
task.streak = 0 unless user . stats . buffs . streaks
else
calculateDelta ( )
addPoints ( ) # obviously for delta>0, but also a trick to undo accidental checkboxes
if direction is ' up '
task.streak = if task . streak then task . streak + 1 else 1
# Give a streak achievement when the streak is a multiple of 21
if ( task . streak % 21 ) is 0
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
if ( task . streak % 21 ) is 0
user.achievements.streak = if user . achievements . streak then user . achievements . streak - 1 else 0
task.streak = if task . streak then task . streak - 1 else 0
when ' todo '
if options . cron
calculateDelta ( )
#don't touch stats on cron
else
2014-01-20 01:09:29 +00:00
task.dateCompleted = if direction is ' up ' then new Date else undefined
2014-01-07 17:36:24 +00:00
calculateDelta ( )
addPoints ( ) # obviously for delta>0, but also a trick to undo accidental checkboxes
2014-01-09 19:16:39 +00:00
# MP++ per checklist item in ToDo, bonus per CLI
2014-02-01 16:26:26 +00:00
multiplier = _ . max ( [ ( _ . reduce ( task . checklist , ( (m,i)-> m + ( if i . completed then 1 else 0 ) ) , 1 ) ) , 1 ] )
2014-01-09 19:16:39 +00:00
mpDelta = _ . max ( [ ( multiplier ) , ( . 01 * user . _statsComputed . maxMP * multiplier ) ] )
2014-01-07 17:36:24 +00:00
mpDelta *= - 1 if direction is ' down ' # unticking a todo
user . stats . mp += mpDelta
user.stats.mp = user . _statsComputed . maxMP if user . stats . mp >= user . _statsComputed . maxMP
user.stats.mp = 0 if user . stats . mp < 0 # BUT DO WE WANT THIS? SEE COMMIT DESCRIPTION
when ' reward '
# Don't adjust values for rewards
2013-12-11 16:30:07 +00:00
calculateDelta ( )
2014-01-07 17:36:24 +00:00
# purchase item
stats . gp -= Math . abs ( task . value )
num = parseFloat ( task . value ) . toFixed ( 2 )
# if too expensive, reduce health & zero gp
if stats . gp < 0
# hp - gp difference
stats . hp += stats . gp
stats.gp = 0
user . fns . updateStats stats
# 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 '
user . fns . randomDrop ( { task , delta } ) if direction is ' up '
cb ? null , user
return delta
2013-12-11 16:30:07 +00:00
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
2013-12-15 18:26:13 +00:00
handleTwoHanded: (item, type='equipped') ->
# If they're buying a shield and wearing a staff, dequip the staff
if item . type is " shield " and ( weapon = content . gear . flat [ user . items . gear [ type ] . weapon ] ) ? . twoHanded
user . items . gear [ type ] . weapon = ' weapon_base_0 '
message = " #{ weapon . text } is two-handed "
# If they're buying a staff and wearing a shield, dequip the shield
if item . twoHanded
user . items . gear [ type ] . shield = " shield_base_0 "
message = " #{ item . text } is two-handed "
message
2013-12-14 19:48:43 +00:00
# ##
Because the same op needs to be performed on the client and the server ( critical hits , item drops , etc ) ,
we need things to be " random " , but technically predictable so that they don ' t go out-of-sync
# ##
predictableRandom: (seed) ->
# Default seed is all user stats combined. Fairly safe, meh - pass in a good seed for situations where that doesn't work
seed = _ . reduce ( user . stats , ( (m,v)-> if _ . isNumber ( v ) then m + v else m ) , 0 ) if ! seed or seed is Math . PI
x = Math . sin ( seed ++ ) * 10000
x - Math . floor ( x )
2013-12-21 17:56:05 +00:00
crit: (stat='str', chance=.03) ->
2014-03-02 18:40:33 +00:00
#console.log("Crit Chance:"+chance*(1+user._statsComputed[stat]/100))
if user . fns . predictableRandom ( ) <= chance * ( 1 + user . _statsComputed [ stat ] / 100 ) then 1.5 + ( . 02 * user . _statsComputed [ stat ] )
2013-12-21 17:56:05 +00:00
else 1
2013-12-14 19:48:43 +00:00
# ##
Get a random property from an object
returns random property ( the value )
# ##
randomVal: (obj, options) ->
2014-01-11 08:43:07 +00:00
array = if options ? . key then _ . keys ( obj ) else _ . values ( obj )
rand = user . fns . predictableRandom ( option ? . seed )
array [ Math . floor ( rand * array . length ) ]
2013-12-14 19:48:43 +00:00
2013-12-11 16:30:07 +00:00
# ##
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) ->
2013-12-24 16:32:15 +00:00
{ task } = modifiers
2013-12-13 22:11:04 +00:00
2013-12-24 17:24:45 +00:00
# % chance of getting a drop
2014-02-16 17:12:04 +00:00
2014-03-02 18:40:33 +00:00
chance = _ . min ( [ Math . abs ( task . value - 21.27 ) , 37.5 ] ) / 150 + . 02 # Base drop chance is a percentage based on task value. Typical fresh task: 15%. Very ripe task: 25%. Very blue task: 2%.
2014-02-16 17:12:04 +00:00
chance *=
2014-02-17 05:52:26 +00:00
task . priority * # Task priority: +50% for Medium, +100% for Hard
( 1 + ( task . streak / 100 or 0 ) ) * # Streak bonus: +1% per streak
2014-03-02 00:56:09 +00:00
( 1 + ( user . _statsComputed . per / 100 ) ) * # PERception: +1% per point
2014-03-02 18:40:33 +00:00
( 1 + ( user . contributor . level / 20 or 0 ) ) * # Contrib levels: +5% per level
( 1 + ( user . achievements . rebirths / 20 or 0 ) ) * # Rebirths: +5% per achievement
2014-03-02 00:56:09 +00:00
( 1 + ( user . achievements . streak / 200 or 0 ) ) * # Streak achievements: +0.5% per achievement
2014-02-16 17:12:04 +00:00
( user . _tmp . crit or 1 ) * # Use the crit multiplier if we got one
2014-03-02 18:40:33 +00:00
( 1 + . 5 * ( _ . reduce ( task . checklist , ( (m,i)-> m + ( if i . completed then 1 else 0 ) ) , 0 ) or 0 ) ) # +50% per checklist item complete. TODO: make this into X individual drop chances instead
chance = api . diminishingReturns ( chance , 0.75 )
2014-02-16 17:12:04 +00:00
#console.log("Drop chance: " + chance)
2013-12-24 16:32:15 +00:00
2013-12-24 17:24:45 +00:00
quest = content . quests [ user . party . quest ? . key ]
2014-02-16 17:12:04 +00:00
if quest ? . collect and user . fns . predictableRandom ( user . stats . gp ) < ( chance * 2 ) # 2x as likely to get a collection quest drop as a pet item drop
2013-12-24 17:24:45 +00:00
dropK = user . fns . randomVal quest . collect , { key : true }
2013-12-25 04:57:46 +00:00
user . party . quest . progress . collect [ dropK ] ++
user . markModified ? ' party.quest.progress '
2014-01-30 22:20:44 +00:00
#console.log {progress:user.party.quest.progress}
2013-12-24 17:24:45 +00:00
2014-03-04 02:16:35 +00:00
return if ( api . daysSince ( user . items . lastDrop . date , user . preferences ) is 0 ) and ( user . items . lastDrop . count >= 5 + Math . floor ( user . _statsComputed . per / 25 ) )
2013-12-24 16:32:15 +00:00
if user . flags ? . dropsEnabled and user . fns . predictableRandom ( user . stats . exp ) < chance
2013-12-13 22:11:04 +00:00
# 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
2013-12-18 06:24:38 +00:00
rarity = user . fns . predictableRandom ( user . stats . gp )
2013-12-13 22:11:04 +00:00
# Food: 40% chance
if rarity > . 6
2014-02-01 23:44:56 +00:00
drop = user . fns . randomVal _ ( content . food ) . omit ( ' Saddle ' ) . where ( { canBuy : true } ) . value ( )
2013-12-23 05:56:37 +00:00
user . items . food [ drop . key ] ? = 0
user . items . food [ drop . key ] += 1
2013-12-13 22:11:04 +00:00
drop.type = ' Food '
2013-12-22 04:01:14 +00:00
drop.dialog = " You ' ve found #{ drop . article } #{ drop . text } ! #{ drop . notes } "
2013-12-13 22:11:04 +00:00
# Eggs: 30% chance
else if rarity > . 3
2014-01-21 22:49:56 +00:00
drop = user . fns . randomVal _ . where ( content . eggs , { canBuy : true } )
2013-12-23 05:56:37 +00:00
user . items . eggs [ drop . key ] ? = 0
user . items . eggs [ drop . key ] ++
2013-12-13 22:11:04 +00:00
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%)
2014-03-07 04:34:43 +00:00
if rarity < . 02 then [ ' Golden ' ]
2013-12-13 22:11:04 +00:00
# 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
2013-12-14 19:48:43 +00:00
drop = user . fns . randomVal _ . pick ( content . hatchingPotions , ( (v,k) -> k in acceptableDrops ) )
2013-12-13 22:11:04 +00:00
2013-12-23 05:56:37 +00:00
user . items . hatchingPotions [ drop . key ] ? = 0
user . items . hatchingPotions [ drop . key ] ++
2013-12-13 22:11:04 +00:00
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-30 17:39:53 +00:00
autoAllocate: ->
2014-01-04 23:35:10 +00:00
user . stats [ ( ->
switch user . preferences . allocationMode
when " flat "
2014-01-05 01:10:59 +00:00
# Favor in order (right-to-left): INT, PER, STR, CON
stats = _ . pick user . stats , $w ' con str per int '
_ . invert ( stats ) [ _ . min stats ]
2014-01-04 23:35:10 +00:00
when " classbased "
# Attributes get 3:2:1:1 per 7 levels.
ideal = [ ( user . stats . lvl / 7 * 3 ) , ( user . stats . lvl / 7 * 2 ) , ( user . stats . lvl / 7 ) , ( user . stats . lvl / 7 ) ]
# Primary, secondary etc. attributes aren't explicitly defined, so hardcode them. In order as above
preference = switch user . stats . class
when " wizard " then [ " int " , " per " , " con " , " str " ]
when " rogue " then [ " per " , " str " , " int " , " con " ]
when " healer " then [ " con " , " int " , " str " , " per " ]
else [ " str " , " con " , " per " , " int " ]
# Get the difference between the ideal attribute spread according to level, and the user's current spread.
diff = [ ( user . stats [ preference [ 0 ] ] - ideal [ 0 ] ) , ( user . stats [ preference [ 1 ] ] - ideal [ 1 ] ) , ( user . stats [ preference [ 2 ] ] - ideal [ 2 ] ) , ( user . stats [ preference [ 3 ] ] - ideal [ 3 ] ) ]
suggested = _ . findIndex ( diff , ( (val) -> if val is _ . min ( diff ) then true ) ) # Returns the index of the first attribute that's furthest behind the ideal
return if ~ suggested then preference [ suggested ] else " str " # If _.findIndex failed, we'd get a -1...
when " taskbased "
2014-01-05 01:10:59 +00:00
suggested = _ . invert ( user . stats . training ) [ _ . max user . stats . training ] # Returns the stat that's been trained up the most this level
_ . merge user . stats . training , { str : 0 , int : 0 , con : 0 , per : 0 } # Reset training for this level.
2014-01-04 23:35:10 +00:00
return suggested or " str " # Failed _.findkey gives undefined
else " str " # if all else fails, dump into STR
) ( ) ] ++
2013-12-30 17:39:53 +00:00
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
2014-01-04 23:35:10 +00:00
user . fns . autoAllocate ( )
2013-12-14 00:50:09 +00:00
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
2014-01-07 14:04:50 +00:00
if user . items . eggs [ " Wolf " ] > 0 then user . items . eggs [ " Wolf " ] ++ else user . items . eggs [ " Wolf " ] = 1
2013-12-21 22:05:04 +00:00
if ! user . flags . classSelected and user . stats . lvl >= 10
2013-12-13 22:55:47 +00:00
user . flags . classSelected
2014-02-02 00:14:47 +00:00
if ! user . flags . levelDrops ? . vice1 and user . stats . lvl >= 30
user . items . quests . vice1 ? = 0
user . items . quests . vice1 ++
( user . flags . levelDrops ? = { } ) . vice1 = true
user . markModified ? ' flags.levelDrops '
user._tmp.drop = _ . defaults content . quests . vice1 ,
type: ' Quest '
dialog: " You ' ve found the quest \" #{ content . quests . vice1 . text } \" ! "
2013-12-24 15:04:18 +00:00
if ! user . flags . rebirthEnabled and ( user . stats . lvl >= 50 or user . achievements . ultimateGear or user . achievements . beastMaster )
2013-12-24 15:09:48 +00:00
user.flags.rebirthEnabled = true
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={}) ->
2013-12-20 19:42:31 +00:00
now = + options . now || + new Date
2013-12-13 22:11:04 +00:00
# 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
2014-01-05 15:14:21 +00:00
user . stats . mp += _ . max ( [ 10 , . 1 * user . _statsComputed . maxMP ] )
2013-12-16 18:11:47 +00:00
user.stats.mp = user . _statsComputed . maxMP if user . stats . mp > user . _statsComputed . maxMP
2014-01-15 23:26:37 +00:00
# "Perfect Day" achievement for perfect-days
2014-01-18 04:41:20 +00:00
perfect = true
clearBuffs = { str : 0 , int : 0 , per : 0 , con : 0 , stealth : 0 , streaks : false }
2014-01-15 23:26:37 +00:00
2013-12-13 22:11:04 +00:00
# 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
2013-12-16 19:54:46 +00:00
if user . preferences . sleep is true
2014-01-15 23:26:37 +00:00
user.stats.buffs = clearBuffs
2013-12-16 19:54:46 +00:00
return
2013-12-13 22:11:04 +00:00
2013-12-23 05:56:37 +00:00
# Tally each task
2013-12-13 22:11:04 +00:00
todoTally = 0
2013-12-25 04:57:46 +00:00
user . party . quest . progress . down ? = 0
2013-12-13 22:11:04 +00:00
user . todos . concat ( user . dailys ) . forEach (task) ->
return unless task
{ id , type , completed , repeat } = task
2014-01-19 01:53:38 +00:00
return if ( type is ' daily ' ) && ! completed && user . stats . buffs . stealth && user . stats . buffs . stealth - - # User "evades" a certain number of uncompleted dailies
2014-02-09 00:56:07 +00:00
2013-12-13 22:11:04 +00:00
# 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
2014-01-18 04:41:20 +00:00
perfect = false if type is ' daily '
2013-12-23 05:56:37 +00:00
delta = user . ops . score ( { params : { id : task . id , direction : ' down ' } , query : { times : scheduleMisses , cron : true } } ) ;
2013-12-25 04:57:46 +00:00
user . party . quest . progress . down += delta if type is ' daily '
2013-12-13 22:11:04 +00:00
switch type
when ' daily '
( task . history ? = [ ] ) . push ( { date: + new Date , value: task . value } )
task.completed = false
2013-12-30 20:23:48 +00:00
_ . each task . checklist , ( (i)-> i . completed = false ; true )
2013-12-13 22:11:04 +00:00
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
2014-01-18 04:41:20 +00:00
user.stats.buffs =
if perfect
user . achievements . perfect ? = 0
user . achievements . perfect ++
lvlDiv2 = Math . ceil ( user . stats . lvl / 2 )
{ str : lvlDiv2 , int : lvlDiv2 , per : lvlDiv2 , con : lvlDiv2 , stealth : 0 , streaks : false }
else clearBuffs
2013-12-20 19:42:31 +00:00
2013-12-25 04:57:46 +00:00
# After all is said and done, progress up user's effect on quest, return those values & reset the user's
progress = user . party . quest . progress ; _progress = _ . cloneDeep progress
_ . merge progress , { down : 0 , up : 0 }
progress.collect = _ . transform progress . collect , ( (m,v,k)-> m [ k ] = 0 )
_progress
2013-12-13 22:11:04 +00:00
# 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
2014-01-24 06:02:51 +00:00
# ----------------------------------------------------------------------
# Achievements
# ----------------------------------------------------------------------
ultimateGear: () ->
# on the server this is a LoDash transform, on the client its an object
gear = if window ? then user . items . gear . owned else user . items . gear . owned . toObject ( )
ownedLastGear = _ . chain ( content . gear . flat )
. pick ( _ . keys gear )
. values ( )
. filter (gear) -> gear . last
lastGearClassTypeMatrix = { }
_ . each content . classes , (klass) ->
lastGearClassTypeMatrix [ klass ] = { }
2014-03-21 20:02:29 +00:00
#_.each content.gearTypes, (type) ->
_ . each [ ' armor ' , ' weapon ' , ' shield ' , ' head ' ] , (type) ->
2014-01-24 06:02:51 +00:00
lastGearClassTypeMatrix [ klass ] [ type ] = false
return true # false exits the each loop early
ownedLastGear . each (gear) ->
lastGearClassTypeMatrix [ gear . klass ] [ " shield " ] = true if gear . twoHanded
lastGearClassTypeMatrix [ gear . klass ] [ gear . type ] = true
shouldGrant = _ ( lastGearClassTypeMatrix )
. values ( )
. reduce ( ( (ans, klass) -> ans or _ ( klass ) . values ( ) . reduce ( ( (ans, gearType) -> ans and gearType ) , true ) ) , false )
. valueOf ( )
user.achievements.ultimateGear = shouldGrant
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: ->
2014-01-07 17:36:24 +00:00
computed = _ . reduce [ ' per ' , ' con ' , ' str ' , ' int ' ] , (m,stat) =>
m [ stat ] = _ . reduce $w ( ' stats stats.buffs items.gear.equipped.weapon items.gear.equipped.armor items.gear.equipped.head items.gear.equipped.shield ' ) , (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
2014-01-07 17:36:24 +00:00
item = content . gear . flat [ val ]
( + item ? [ stat ] or 0 ) * ( if item ? . klass is user . stats . class then 1.5 else 1 )
2013-12-13 02:28:29 +00:00
else
+ val [ stat ] or 0
2014-01-07 17:36:24 +00:00
, 0
2013-12-13 22:55:47 +00:00
m [ stat ] += ( user . stats . lvl - 1 ) / 2
m
2014-01-07 17:36:24 +00:00
, { }
2013-12-16 18:09:03 +00:00
computed.maxMP = computed . int * 2 + 30
computed
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 )