mirror of
https://github.com/sudoxnym/habitica.git
synced 2026-05-21 21:28:52 +00:00
[https://github.com/HabitRPG/habitrpg/issues/1977] APIv2 WIP - start of a framework where operations are shared between client & server. If the op is called on the client, it updates the user & then POSTs to the server with op of the same name. If called on server, it updates the user and user.save()s
This commit is contained in:
parent
9bee4b9f9e
commit
954cdf7f29
8 changed files with 2350 additions and 2352 deletions
|
|
@ -33,7 +33,7 @@ module.exports = function(grunt) {
|
|||
|
||||
browserify: {
|
||||
dist: {
|
||||
src: ["script/index.js"],
|
||||
src: ["index.js"],
|
||||
dest: "dist/habitrpg-shared.js"
|
||||
},
|
||||
options: {
|
||||
|
|
|
|||
2765
dist/habitrpg-shared.js
vendored
2765
dist/habitrpg-shared.js
vendored
File diff suppressed because it is too large
Load diff
9
index.js
Normal file
9
index.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
module.exports = require('./script/index.coffee');
|
||||
var _ = require('lodash');
|
||||
var moment = require('moment');
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.habitrpgShared = module.exports;
|
||||
window._ = _;
|
||||
window.moment = moment;
|
||||
}
|
||||
|
|
@ -1,517 +0,0 @@
|
|||
moment = require('moment')
|
||||
_ = require('lodash')
|
||||
helpers = require('./helpers.coffee')
|
||||
items = require('./items.coffee')
|
||||
{eggs, hatchingPotions} = items.items
|
||||
|
||||
XP = 15
|
||||
HP = 2
|
||||
obj = module.exports = {}
|
||||
|
||||
obj.defineComputed = (user) ->
|
||||
#FIXME we should put this function in a sane place instead of this check, somewhere (both on client & server) where
|
||||
# it will only define the property once on user initialization
|
||||
return if user._statsComputed?
|
||||
|
||||
# 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) =>
|
||||
val = helpers.dotGet(path, @)
|
||||
m2 +
|
||||
if ~path.indexOf('items.gear')
|
||||
# get the gear stat, and multiply it by 1.2 if it's class-gear
|
||||
(+items.items.gear.flat[val]?[stat] or 0) * (if ~val?.indexOf(@stats.class) then 1.2 else 1)
|
||||
else
|
||||
+val[stat] or 0
|
||||
, 0); m
|
||||
, {})
|
||||
|
||||
obj.revive = (user)->
|
||||
|
||||
# Reset stats
|
||||
user.stats.hp = 50
|
||||
user.stats.exp = 0
|
||||
user.stats.gp = 0
|
||||
user.stats.lvl-- if user.stats.lvl > 1
|
||||
|
||||
# Lose a stat point
|
||||
lostStat = helpers.randomVal _.reduce ['str','con','per','int'], ((m,k)->(m[k]=k if user.stats[v];m)), {}
|
||||
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 = items.items.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'
|
||||
|
||||
obj.priorityValue = (priority = '!') ->
|
||||
switch priority
|
||||
when '!' then 1
|
||||
when '!!' then 1.5
|
||||
when '!!!' then 2
|
||||
else 1
|
||||
|
||||
obj.tnl = (level) ->
|
||||
if level >= 100
|
||||
value = 0
|
||||
else
|
||||
value = Math.round(((Math.pow(level, 2) * 0.25) + (10 * level) + 139.75) / 10) * 10
|
||||
# round to nearest 10
|
||||
return value
|
||||
|
||||
###
|
||||
Calculates Exp modificaiton based on level and weapon strength
|
||||
{value} task.value for exp gain
|
||||
{weaponStrength) weapon strength
|
||||
{level} current user level
|
||||
{priority} user-defined priority multiplier
|
||||
###
|
||||
obj.expModifier = (value, weaponStr, level, priority = '!') ->
|
||||
str = (level - 1) / 2
|
||||
# ultimately get this from user
|
||||
totalStr = (str + weaponStr) / 100
|
||||
strMod = 1 + totalStr
|
||||
exp = value * XP * strMod * obj.priorityValue(priority)
|
||||
return Math.round(exp)
|
||||
|
||||
###
|
||||
Calculates HP modification based on level and armor defence
|
||||
{value} task.value for hp loss
|
||||
{armorDefense} defense from armor
|
||||
{helmDefense} defense from helm
|
||||
{level} current user level
|
||||
{priority} user-defined priority multiplier
|
||||
###
|
||||
obj.hpModifier = (value, armorDef, helmDef, shieldDef, level, priority = '!') ->
|
||||
def = (level - 1) / 2
|
||||
# ultimately get this from user?
|
||||
totalDef = (def + armorDef + helmDef + shieldDef) / 100
|
||||
#ultimate get this from user
|
||||
defMod = 1 - totalDef
|
||||
hp = value * HP * defMod * obj.priorityValue(priority)
|
||||
return Math.round(hp * 10) / 10
|
||||
# round to 1dp
|
||||
|
||||
###
|
||||
Future use
|
||||
{priority} user-defined priority multiplier
|
||||
###
|
||||
obj.gpModifier = (value, modifier, priority = '!', streak, user) ->
|
||||
val = value * modifier * obj.priorityValue(priority)
|
||||
if streak and user
|
||||
streakBonus = streak / 100 + 1 # eg, 1-day streak is 1.1, 2-day is 1.2, etc
|
||||
afterStreak = val * streakBonus
|
||||
user._tmp.streakBonus = afterStreak - val if (val > 0) # keep this on-hand for later, so we can notify streak-bonus
|
||||
return afterStreak
|
||||
else
|
||||
return val
|
||||
|
||||
###
|
||||
Calculates the next task.value based on direction
|
||||
Uses a capped inverse log y=.95^x, y>= -5
|
||||
{currentValue} the current value of the task
|
||||
{direction} up or down
|
||||
###
|
||||
obj.taskDeltaFormula = (currentValue, direction) ->
|
||||
if currentValue < -47.27 then currentValue = -47.27
|
||||
else if currentValue > 21.27 then currentValue = 21.27
|
||||
delta = Math.pow(0.9747, currentValue)
|
||||
return delta if direction is 'up'
|
||||
return -delta
|
||||
|
||||
|
||||
###
|
||||
Drop System
|
||||
###
|
||||
|
||||
randomDrop = (user, 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 = (helpers.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 *= obj.priorityValue(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 = helpers.randomVal _.omit(items.items.food, 'Saddle')
|
||||
user.items.food[drop.name] ?= 0
|
||||
user.items.food[drop.name]+= 1
|
||||
drop.type = 'Food'
|
||||
drop.dialog = "You've found a #{drop.text} Food! #{drop.notes}"
|
||||
|
||||
# Eggs: 30% chance
|
||||
else if rarity > .3
|
||||
drop = helpers.randomVal eggs
|
||||
user.items.eggs[drop.name] ?= 0
|
||||
user.items.eggs[drop.name]++
|
||||
drop.type = 'Egg'
|
||||
drop.dialog = "You've found a #{drop.text} Egg! #{drop.notes}"
|
||||
|
||||
# Hatching Potion, 30% chance - break down by rarity.
|
||||
else
|
||||
acceptableDrops =
|
||||
# Very Rare: 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 = helpers.randomVal _.pick(hatchingPotions, ((v,k) -> k in acceptableDrops))
|
||||
|
||||
user.items.hatchingPotions[drop.name] ?= 0
|
||||
user.items.hatchingPotions[drop.name]++
|
||||
drop.type = 'HatchingPotion'
|
||||
drop.dialog = "You've found a #{drop.text} Hatching Potion! #{drop.notes}"
|
||||
|
||||
# if they've dropped something, we want the consuming client to know so they can notify the user. See how the Derby
|
||||
# app handles it for example. Would this be better handled as an emit() ?
|
||||
user._tmp.drop = drop
|
||||
|
||||
user.items.lastDrop.date = +new Date
|
||||
user.items.lastDrop.count++
|
||||
|
||||
|
||||
# {task} task you want to score
|
||||
# {direction} 'up' or 'down'
|
||||
obj.score = (user, task, direction, options={}) ->
|
||||
|
||||
# This is for setting one-time temporary flags, such as streakBonus or itemDropped. Useful for notifying
|
||||
# the API consumer, then cleared afterwards
|
||||
user._tmp = {}
|
||||
|
||||
obj.defineComputed user # put this somewhere else?
|
||||
console.log computed: user._statsComputed
|
||||
|
||||
[gp, hp, exp, lvl] = [+user.stats.gp, +user.stats.hp, +user.stats.exp, ~~user.stats.lvl]
|
||||
[type, value, streak, priority] = [task.type, +task.value, ~~task.streak, task.priority or '!']
|
||||
[paths, times, cron] = [options.paths || {}, options.times || 1, options.cron || false]
|
||||
|
||||
# Handle corrupt tasks
|
||||
# This type of cleanup-code shouldn't be necessary, revisit once we're off Derby
|
||||
# return 0 unless task.id
|
||||
# if !_.isNumber(value) or _.isNaN(value)
|
||||
# task.value = value = 0;
|
||||
# _.each user.stats, (v,k) ->
|
||||
# if !_.isNumber(v) or _.isNaN(v)
|
||||
# user.stats[k] = 0; paths["stats.#{k}"] = true
|
||||
|
||||
# If they're trying to purhcase a too-expensive reward, don't allow them to do that.
|
||||
if task.value > user.stats.gp and task.type is 'reward'
|
||||
return
|
||||
|
||||
delta = 0
|
||||
calculateDelta = (adjustvalue = true) ->
|
||||
# If multiple days have passed, multiply times days missed
|
||||
_.times times, ->
|
||||
# Each iteration calculate the delta (nextDelta), which is then accumulated in delta
|
||||
# (aka, the total delta). This weirdness won't be necessary when calculating mathematically
|
||||
# rather than iteratively
|
||||
nextDelta = obj.taskDeltaFormula(value, direction)
|
||||
value += nextDelta if adjustvalue
|
||||
delta += nextDelta
|
||||
|
||||
addPoints = ->
|
||||
weaponStr = items.getItem(user, 'weapon').str
|
||||
exp += obj.expModifier(delta, weaponStr, user.stats.lvl, priority) / 2 # /2 hack for now, people leveling too fast
|
||||
if streak
|
||||
gp += obj.gpModifier(delta, 1, priority, streak, user)
|
||||
else
|
||||
gp += obj.gpModifier(delta, 1, priority)
|
||||
|
||||
|
||||
subtractPoints = ->
|
||||
armorDef = items.getItem(user, 'armor').con
|
||||
headDef = items.getItem(user, 'head').con
|
||||
shieldDef = items.getItem(user, 'shield').con
|
||||
hp += obj.hpModifier(delta, armorDef, headDef, shieldDef, user.stats.lvl, priority)
|
||||
|
||||
switch type
|
||||
when 'habit'
|
||||
calculateDelta()
|
||||
# Add habit value to habit-history (if different)
|
||||
if (delta > 0) then addPoints() else subtractPoints()
|
||||
if task.value != value
|
||||
(task.history ?= []).push { date: +new Date, value: value }; paths["tasks.#{task.id}.history"] = true
|
||||
|
||||
when 'daily'
|
||||
if cron
|
||||
calculateDelta()
|
||||
subtractPoints()
|
||||
task.streak = 0
|
||||
else
|
||||
calculateDelta()
|
||||
addPoints() # obviously for delta>0, but also a trick to undo accidental checkboxes
|
||||
if direction is 'up'
|
||||
streak = if streak then streak + 1 else 1
|
||||
|
||||
# Give a streak achievement when the streak is a multiple of 21
|
||||
if (streak % 21) is 0
|
||||
user.achievements.streak = if user.achievements.streak then user.achievements.streak + 1 else 1
|
||||
paths["achievements.streak"] = true
|
||||
|
||||
else
|
||||
# Remove a streak achievement if streak was a multiple of 21 and the daily was undone
|
||||
if (streak % 21) is 0
|
||||
user.achievements.streak = if user.achievements.streak then user.achievements.streak - 1 else 0
|
||||
paths["achievements.streak"] = true
|
||||
|
||||
streak = if streak then streak - 1 else 0
|
||||
task.streak = streak
|
||||
|
||||
paths["tasks.#{task.id}.streak"] = true
|
||||
|
||||
when 'todo'
|
||||
if cron
|
||||
calculateDelta()
|
||||
#don't touch stats on cron
|
||||
else
|
||||
calculateDelta()
|
||||
addPoints() # obviously for delta>0, but also a trick to undo accidental checkboxes
|
||||
|
||||
when 'reward'
|
||||
# Don't adjust values for rewards
|
||||
calculateDelta(false)
|
||||
# purchase item
|
||||
gp -= Math.abs(task.value)
|
||||
num = parseFloat(task.value).toFixed(2)
|
||||
# if too expensive, reduce health & zero gp
|
||||
if gp < 0
|
||||
hp += gp
|
||||
# hp - gp difference
|
||||
gp = 0
|
||||
|
||||
task.value = value; paths["tasks.#{task.id}.value"] = true
|
||||
updateStats user, { hp, exp, gp }, {paths: paths}
|
||||
|
||||
# Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results)
|
||||
if typeof window is 'undefined'
|
||||
randomDrop(user, {delta, priority, streak}) if direction is 'up'
|
||||
|
||||
return delta
|
||||
|
||||
###
|
||||
Updates user stats with new stats. Handles death, leveling up, etc
|
||||
{stats} new stats
|
||||
{update} if aggregated changes, pass in userObj as update. otherwise commits will be made immediately
|
||||
###
|
||||
updateStats = (user, newStats, options={}) ->
|
||||
paths = options.paths || {}
|
||||
# if user is dead, dont do anything
|
||||
return if user.stats.hp <= 0
|
||||
|
||||
if newStats.hp?
|
||||
# Game Over
|
||||
if newStats.hp <= 0
|
||||
user.stats.hp = 0
|
||||
return
|
||||
else
|
||||
user.stats.hp = newStats.hp
|
||||
|
||||
if newStats.exp?
|
||||
tnl = obj.tnl(user.stats.lvl)
|
||||
#silent = false
|
||||
# if we're at level 100, turn xp to gold
|
||||
if user.stats.lvl >= 100
|
||||
newStats.gp += newStats.exp / 15
|
||||
newStats.exp = 0
|
||||
user.stats.lvl = 100
|
||||
else
|
||||
# level up & carry-over exp
|
||||
if newStats.exp >= tnl
|
||||
#silent = true # push through the negative xp silently
|
||||
user.stats.exp = newStats.exp # push normal + notification
|
||||
while newStats.exp >= tnl and user.stats.lvl < 100 # keep levelling up
|
||||
newStats.exp -= tnl
|
||||
user.stats.lvl++
|
||||
tnl = obj.tnl(user.stats.lvl)
|
||||
if user.stats.lvl == 100
|
||||
newStats.exp = 0
|
||||
user.stats.hp = 50
|
||||
|
||||
user.stats.exp = newStats.exp
|
||||
|
||||
# Set flags when they unlock features
|
||||
user.flags ?= {}
|
||||
if !user.flags.customizationsNotification and (user.stats.exp > 10 or user.stats.lvl > 1)
|
||||
user.flags.customizationsNotification = true
|
||||
if !user.flags.itemsEnabled and user.stats.lvl >= 2
|
||||
user.flags.itemsEnabled = true
|
||||
if !user.flags.partyEnabled and user.stats.lvl >= 3
|
||||
user.flags.partyEnabled = true
|
||||
if !user.flags.dropsEnabled and user.stats.lvl >= 4
|
||||
user.flags.dropsEnabled = true
|
||||
user.items.eggs["Wolf"] = 1
|
||||
if !user.flags.classSelected and user.stats.lvl >= 5
|
||||
user.flags.classSelected
|
||||
|
||||
if newStats.gp?
|
||||
#FIXME what was I doing here? I can't remember, gp isn't defined
|
||||
gp = 0.0 if (!gp? or gp < 0)
|
||||
user.stats.gp = newStats.gp
|
||||
|
||||
###
|
||||
At end of day, add value to all incomplete Daily & Todo tasks (further incentive)
|
||||
For incomplete Dailys, deduct experience
|
||||
Make sure to run this function once in a while as server will not take care of overnight calculations.
|
||||
And you have to run it every time client connects.
|
||||
{user}
|
||||
###
|
||||
obj.cron = (user, options={}) ->
|
||||
[paths, now] = [options.paths || {}, +options.now || +new Date]
|
||||
|
||||
# They went to a different timezone
|
||||
# FIXME:
|
||||
# (1) This exit-early code isn't taking timezone into consideration!!
|
||||
# (2) Won't switching timezones be handled automatically client-side anyway? (aka, can we just remove this code?)
|
||||
# (3) And if not, is this the correct way to handle switching timezones
|
||||
# if moment(user.lastCron).isAfter(now)
|
||||
# user.lastCron = now
|
||||
# return
|
||||
|
||||
daysMissed = helpers.daysSince user.lastCron, _.defaults({now}, user.preferences)
|
||||
return unless daysMissed > 0
|
||||
|
||||
user.lastCron = now; paths['lastCron'] = true
|
||||
|
||||
# Reset the lastDrop count to zero
|
||||
if user.items.lastDrop.count > 0
|
||||
user.items.lastDrop.count = 0
|
||||
paths['items.lastDrop'] = true
|
||||
|
||||
# User is resting at the inn. Used to be we un-checked each daily without performing calculation (see commits before fb29e35)
|
||||
# but to prevent abusing the inn (http://goo.gl/GDb9x) we now do *not* calculate dailies, and simply set lastCron to today
|
||||
return if user.flags.rest is true
|
||||
|
||||
# Tally each task
|
||||
todoTally = 0
|
||||
user.todos.concat(user.dailys).forEach (task) ->
|
||||
return unless task
|
||||
|
||||
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 helpers.shouldDo(thatDay, repeat, user.preferences)
|
||||
obj.score(user, task, 'down', {times:scheduleMisses, cron:true, paths:paths}) if scheduleMisses > 0
|
||||
|
||||
switch type
|
||||
when 'daily'
|
||||
(task.history ?= []).push({ date: +new Date, value: task.value }); paths["tasks.#{task.id}.history"] = true
|
||||
task.completed = false; paths["tasks.#{task.id}.completed"] = true;
|
||||
when 'todo'
|
||||
#get updated value
|
||||
absVal = if (completed) then Math.abs(task.value) else task.value
|
||||
todoTally += absVal
|
||||
|
||||
user.habits.forEach (task) -> # slowly reset 'onlies' value to 0
|
||||
if task.up is false or task.down is false
|
||||
if Math.abs(task.value) < 0.1
|
||||
task.value = 0
|
||||
else
|
||||
task.value = task.value / 2
|
||||
paths["tasks.#{task.id}.value"] = true
|
||||
|
||||
|
||||
# Finished tallying
|
||||
((user.history ?= {}).todos ?= []).push { date: now, value: todoTally }
|
||||
# tally experience
|
||||
expTally = user.stats.exp
|
||||
lvl = 0 #iterator
|
||||
while lvl < (user.stats.lvl - 1)
|
||||
lvl++
|
||||
expTally += obj.tnl(lvl)
|
||||
(user.history.exp ?= []).push { date: now, value: expTally }
|
||||
paths["history"] = true
|
||||
obj.preenUserHistory(user) # we can probably start removing paths[...] stuff, no longer used by our app
|
||||
user
|
||||
|
||||
###
|
||||
Preen history for users with > 7 history entries
|
||||
This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array
|
||||
of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week
|
||||
of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite]
|
||||
###
|
||||
preenHistory = (history) ->
|
||||
history = _.filter(history, (h) -> !!h) # discard nulls (corrupted somehow)
|
||||
newHistory = []
|
||||
preen = (amount, groupBy) ->
|
||||
groups = _.chain(history)
|
||||
.groupBy((h) -> moment(h.date).format groupBy) # get date groupings to average against
|
||||
.sortBy((h, k) -> k) # sort by date
|
||||
.value() # turn into an array
|
||||
groups = groups.slice(-amount)
|
||||
groups.pop() # get rid of "this week", "this month", etc (except for case of days)
|
||||
_.each groups, (group) ->
|
||||
newHistory.push
|
||||
date: moment(group[0].date).toDate()
|
||||
#date: moment(group[0].date).format('MM/DD/YYYY') # Use this one when testing
|
||||
value: _.reduce(group, ((m, obj) -> m + obj.value), 0) / group.length # average
|
||||
true
|
||||
|
||||
# Keep the last:
|
||||
preen 50, "YYYY" # 50 years (habit will toootally be around that long!)
|
||||
preen moment().format('MM'), "YYYYMM" # last MM months (eg, if today is 05, keep the last 5 months)
|
||||
|
||||
# Then keep all days of this month. Note, the extra logic is to account for Habits, which can be counted multiple times per day
|
||||
# FIXME I'd rather keep 1-entry/week of this month, then last 'd' days in this week. However, I'm having issues where the 1st starts mid week
|
||||
thisMonth = moment().format('YYYYMM')
|
||||
newHistory = newHistory.concat _.filter(history, (h)-> moment(h.date).format('YYYYMM') is thisMonth)
|
||||
#preen Math.ceil(moment().format('D')/7), "YYYYww" # last __ weeks (# weeks so far this month)
|
||||
#newHistory = newHistory.concat(history.slice -moment().format('D')) # each day of this week
|
||||
|
||||
newHistory
|
||||
|
||||
# Registered users with some history
|
||||
obj.preenUserHistory = (user, minHistLen = 7) ->
|
||||
_.each user.habits.concat(user.dailys), (task) ->
|
||||
task.history = preenHistory(task.history) if task.history?.length > minHistLen
|
||||
true
|
||||
|
||||
_.defaults user.history, {todos:[], exp: []}
|
||||
user.history.exp = preenHistory(user.history.exp) if user.history.exp.length > minHistLen
|
||||
user.history.todos = preenHistory(user.history.todos) if user.history.todos.length > minHistLen
|
||||
#user.markModified('history')
|
||||
#user.markModified('habits')
|
||||
#user.markModified('dailys')
|
||||
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
_ = require 'lodash'
|
||||
|
||||
items = module.exports.items = {}
|
||||
api = module.exports
|
||||
|
||||
###
|
||||
---------------------------------------------------------------
|
||||
|
|
@ -9,6 +8,7 @@ items = module.exports.items = {}
|
|||
---------------------------------------------------------------
|
||||
###
|
||||
|
||||
|
||||
gear =
|
||||
weapon:
|
||||
base:
|
||||
|
|
@ -158,7 +158,7 @@ gear =
|
|||
The gear is exported as a tree (defined above), and a flat list (eg, {weapon_healer_1: .., shield_special_0: ...}) since
|
||||
they are needed in different froms at different points in the app
|
||||
###
|
||||
items.gear =
|
||||
api.gear =
|
||||
tree: gear
|
||||
flat: {}
|
||||
|
||||
|
|
@ -168,7 +168,7 @@ _.each ['weapon', 'armor', 'head', 'shield'], (type) ->
|
|||
_.each gear[type][klass], (item, i) ->
|
||||
key = "#{type}_#{klass}_#{i}"
|
||||
_.defaults item, {type, key, klass, index: i, str:0, int:0, per:0, con:0}
|
||||
items.gear.flat[key] = item
|
||||
api.gear.flat[key] = item
|
||||
|
||||
###
|
||||
---------------------------------------------------------------
|
||||
|
|
@ -176,7 +176,7 @@ _.each ['weapon', 'armor', 'head', 'shield'], (type) ->
|
|||
---------------------------------------------------------------
|
||||
###
|
||||
|
||||
items.potion = type: 'potion', text: "Health Potion", notes: "Recover 15 Health (Instant Use)", value: 25, key: 'potion'
|
||||
api.potion = type: 'potion', text: "Health Potion", notes: "Recover 15 Health (Instant Use)", value: 25, key: 'potion'
|
||||
|
||||
###
|
||||
---------------------------------------------------------------
|
||||
|
|
@ -199,7 +199,7 @@ items.potion = type: 'potion', text: "Health Potion", notes: "Recover 15 Health
|
|||
Note, user.stats.mp is docked after automatically (it's appended to functions automatically down below in an _.each)
|
||||
###
|
||||
|
||||
items.spells =
|
||||
api.spells =
|
||||
wizard:
|
||||
fireball:
|
||||
text: 'Burst of Flames'
|
||||
|
|
@ -361,7 +361,7 @@ items.spells =
|
|||
crit = (user) -> (Math.random() * user.stats.per + 1)
|
||||
|
||||
# Intercept all spells to reduce user.stats.mp after casting the spell
|
||||
_.each items.spells, (spellClass) ->
|
||||
_.each api.spells, (spellClass) ->
|
||||
_.each spellClass, (spell, k) ->
|
||||
spell.name = k
|
||||
_cast = spell.cast
|
||||
|
|
@ -376,7 +376,7 @@ _.each items.spells, (spellClass) ->
|
|||
---------------------------------------------------------------
|
||||
###
|
||||
|
||||
items.eggs =
|
||||
api.eggs =
|
||||
# value & other defaults set below
|
||||
Wolf: text: 'Wolf', adjective: 'loyal'
|
||||
TigerCub: text: 'Tiger Cub', mountText: 'Tiger', adjective: 'fierce'
|
||||
|
|
@ -388,20 +388,20 @@ items.eggs =
|
|||
Cactus: text: 'Cactus', adjective: 'prickly'
|
||||
BearCub: text: 'Bear Cub', mountText: 'Bear', adjective: 'cuddly'
|
||||
#{text: 'Polar Bear Cub', name: 'PolarBearCub', value: 3}
|
||||
_.each items.eggs, (egg,k) ->
|
||||
_.each api.eggs, (egg,k) ->
|
||||
_.defaults egg,
|
||||
value: 3
|
||||
name: k
|
||||
notes: "Find a hatching potion to pour on this egg, and it will hatch into a #{egg.adjective} #{egg.text}."
|
||||
mountText: egg.text
|
||||
|
||||
items.specialPets =
|
||||
api.specialPets =
|
||||
'Wolf-Veteran': true
|
||||
'Wolf-Cerberus': true
|
||||
'Dragon-Hydra': true
|
||||
'Turkey-Base': true
|
||||
|
||||
items.hatchingPotions =
|
||||
api.hatchingPotions =
|
||||
Base: value: 2, text: 'Base'
|
||||
White: value: 2, text: 'White'
|
||||
Desert: value: 2, text: 'Desert'
|
||||
|
|
@ -412,10 +412,10 @@ items.hatchingPotions =
|
|||
CottonCandyPink: value: 4, text: 'Cotton Candy Pink'
|
||||
CottonCandyBlue: value: 4, text: 'Cotton Candy Blue'
|
||||
Golden: value: 5, text: 'Golden'
|
||||
_.each items.hatchingPotions, (pot,k) ->
|
||||
_.each api.hatchingPotions, (pot,k) ->
|
||||
_.defaults pot, {name: k, value: 2, notes: "Pour this on an egg, and it will hatch as a #{pot.text} pet."}
|
||||
|
||||
items.food =
|
||||
api.food =
|
||||
Meat: text: 'Meat', target: 'Base'
|
||||
Milk: text: 'Milk', target: 'White'
|
||||
Potatoe: text: 'Potato', target: 'Desert'
|
||||
|
|
@ -431,59 +431,34 @@ items.food =
|
|||
#Watermelon: text: 'Watermelon', target: 'Golden'
|
||||
#SeaWeed: text: 'SeaWeed', target: 'Golden'
|
||||
Saddle: text: 'Saddle', value: 5, notes: 'Instantly raises your pet into a mount.'
|
||||
_.each items.food, (food,k) ->
|
||||
_.each api.food, (food,k) ->
|
||||
_.defaults food, {value: 1, name: k, notes: "Feed this to a pet and it may grow into a sturdy steed."}
|
||||
|
||||
###
|
||||
---------------------------------------------------------------
|
||||
Helper Functions
|
||||
---------------------------------------------------------------
|
||||
###
|
||||
repeat = {m:true,t:true,w:true,th:true,f:true,s:true,su:true}
|
||||
api.userDefaults =
|
||||
habits: [
|
||||
{type: 'habit', text: '1h Productive Work', notes: 'When you create a new Habit, you can click the Edit icon and choose for it to represent a positive habit, a negative habit, or both. For some Habits, like this one, it only makes sense to gain points.', value: 0, up: true, down: false }
|
||||
{type: 'habit', text: 'Eat Junk Food', notes: 'For others, it only makes sense to *lose* points.', value: 0, up: false, down: true}
|
||||
{type: 'habit', text: 'Take The Stairs', notes: 'For the rest, both + and - make sense (stairs = gain, elevator = lose).', value: 0, up: true, down: true}
|
||||
]
|
||||
|
||||
module.exports.buyItem = (user, item) ->
|
||||
return false if !item or +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 getItem(user,'weapon').last && getItem(user,'armor').last && getItem(user,'head').last && getItem(user,'shield').last
|
||||
user.achievements.ultimateGear = true
|
||||
user.stats.gp -= item.value
|
||||
true
|
||||
dailys: [
|
||||
{type: 'daily', text: '1h Personal Project', notes: 'All tasks default to yellow when they are created. This means you will take only moderate damage when they are missed and will gain only a moderate reward when they are completed.', value: 0, completed: false, repeat: repeat }
|
||||
{type: 'daily', text: 'Exercise', notes: 'Dailies you complete consistently will turn from yellow to green to blue, helping you track your progress. The higher you move up the ladder, the less damage you take for missing and less reward you receive for completing the goal.', value: 3, completed: false, repeat: repeat }
|
||||
{type: 'daily', text: '45m Reading', notes: 'If you miss a daily frequently, it will turn darker shades of orange and red. The redder the task is, the more experience and gold it grants for success and the more damage you take for failure. This encourages you to focus on your shortcomings, the reds.', value: -10, completed: false, repeat: repeat }
|
||||
]
|
||||
|
||||
###
|
||||
update store
|
||||
###
|
||||
module.exports.updateStore = (user) ->
|
||||
changes = []
|
||||
_.each ['weapon', 'armor', 'shield', 'head'], (type) ->
|
||||
found = _.find items.gear.tree[type][user.stats.class], (item) ->
|
||||
!user.items.gear.owned[item.key]
|
||||
changes.push(found) if found
|
||||
todos: [
|
||||
{type: 'todo', text: 'Call Mom', notes: 'While not completing a to-do in a set period of time will not hurt you, they will gradually change from yellow to red, thus becoming more valuable. This will encourage you to wrap up stale To-Dos.', value: -3, completed: false }
|
||||
]
|
||||
|
||||
# Add special items (contrib gear, backer gear, etc)
|
||||
_.defaults changes, _.transform _.where(items.gear.flat, {klass:'special'}), (m,v) ->
|
||||
m.push v if v.canOwn?(user) && !user.items.gear.owned[v.key]
|
||||
rewards: [
|
||||
{type: 'reward', text: '1 Episode of Game of Thrones', notes: 'Custom rewards can come in many forms. Some people will hold off watching their favorite show unless they have the gold to pay for it.', value: 20 }
|
||||
{type: 'reward', text: 'Cake', notes: 'Other people just want to enjoy a nice piece of cake. Try to create rewards that will motivate you best.', value: 10 }
|
||||
]
|
||||
|
||||
changes.push items.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
|
||||
|
||||
###
|
||||
Gets an item, and caps max to the last item in its array
|
||||
###
|
||||
module.exports.getItem = getItem = (user, type) ->
|
||||
item = items.gear.flat[user.items.gear.equipped[type]]
|
||||
return items.gear.flat["#{type}_#{user.stats.class}_0"] unless item
|
||||
item
|
||||
tags: [
|
||||
{name: 'morning'}
|
||||
{name: 'afternoon'}
|
||||
{name: 'evening'}
|
||||
]
|
||||
|
|
@ -1,385 +0,0 @@
|
|||
moment = require 'moment'
|
||||
_ = require 'lodash'
|
||||
items = require('./items.coffee')
|
||||
|
||||
###
|
||||
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}
|
||||
|
||||
startOfWeek = (options={}) ->
|
||||
o = sanitizeOptions(options)
|
||||
moment(o.now).startOf('week')
|
||||
|
||||
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
|
||||
###
|
||||
daysSince = (yesterday, options = {}) ->
|
||||
o = sanitizeOptions options
|
||||
Math.abs 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?
|
||||
###
|
||||
shouldDo = (day, repeat, options={}) ->
|
||||
return false unless repeat
|
||||
o = sanitizeOptions options
|
||||
selected = repeat[dayMapping[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?
|
||||
|
||||
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
|
||||
|
||||
module.exports =
|
||||
|
||||
###
|
||||
Moment
|
||||
###
|
||||
moment: moment
|
||||
|
||||
###
|
||||
Lodash
|
||||
###
|
||||
_: _
|
||||
|
||||
uuid: uuid
|
||||
|
||||
taskDefaults: (task, filters={}) ->
|
||||
self = @
|
||||
defaults =
|
||||
id: self.uuid()
|
||||
type: 'habit'
|
||||
text: ''
|
||||
notes: ''
|
||||
priority: '!'
|
||||
challenge: {}
|
||||
tags: _.transform(filters, (m,v,k) -> m[k]=v if v)
|
||||
_.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
|
||||
|
||||
# FIXME - should we remove this completely, since all defaults are accounted for in mongoose?
|
||||
# Or should we keep it so mobile can create an offline new user in the future?
|
||||
newUser: () ->
|
||||
userSchema =
|
||||
# _id / id handled by Racer
|
||||
stats: gp: 0, exp: 0, lvl: 1, hp: 50
|
||||
invitations: {party:null, guilds: []}
|
||||
# items: #TODO removing while handling default items (esp. gear) via Mongoose. Revisit, we may be able to remove userSchema entirely and depend on mongoose
|
||||
# weapon: 0
|
||||
# armor: 0
|
||||
# head: 0
|
||||
# shield: 0
|
||||
# lastDrop: { date: +new Date, count: 0 }
|
||||
# eggs: {}
|
||||
# food: {}
|
||||
# hatchingPotions: {}
|
||||
# pets: {}
|
||||
# mounts: {}
|
||||
# preferences:
|
||||
# gender: 'm'
|
||||
# skin: 'white'
|
||||
# hair: color: 'blond', base: 0, bangs: 1
|
||||
# #armorSet: 'v1'
|
||||
# dayStart:0
|
||||
# showHelm: true
|
||||
# showArmor: true
|
||||
# showWeapon: true
|
||||
# showShield: true
|
||||
apiToken: uuid() # set in newUserObject below
|
||||
lastCron: +new Date
|
||||
balance: 0
|
||||
flags:
|
||||
partyEnabled: false
|
||||
itemsEnabled: false
|
||||
ads: 'show'
|
||||
tags: []
|
||||
|
||||
userSchema.habits = []
|
||||
userSchema.dailys = []
|
||||
userSchema.todos = []
|
||||
userSchema.rewards = []
|
||||
|
||||
# deep clone, else further new users get duplicate objects
|
||||
newUser = _.cloneDeep userSchema
|
||||
|
||||
repeat = {m:true,t:true,w:true,th:true,f:true,s:true,su:true}
|
||||
defaultTasks = [
|
||||
{type: 'habit', text: '1h Productive Work', notes: 'When you create a new Habit, you can click the Edit icon and choose for it to represent a positive habit, a negative habit, or both. For some Habits, like this one, it only makes sense to gain points.', value: 0, up: true, down: false }
|
||||
{type: 'habit', text: 'Eat Junk Food', notes: 'For others, it only makes sense to *lose* points.', value: 0, up: false, down: true}
|
||||
{type: 'habit', text: 'Take The Stairs', notes: 'For the rest, both + and - make sense (stairs = gain, elevator = lose).', value: 0, up: true, down: true}
|
||||
|
||||
{type: 'daily', text: '1h Personal Project', notes: 'All tasks default to yellow when they are created. This means you will take only moderate damage when they are missed and will gain only a moderate reward when they are completed.', value: 0, completed: false, repeat: repeat }
|
||||
{type: 'daily', text: 'Exercise', notes: 'Dailies you complete consistently will turn from yellow to green to blue, helping you track your progress. The higher you move up the ladder, the less damage you take for missing and less reward you receive for completing the goal.', value: 3, completed: false, repeat: repeat }
|
||||
{type: 'daily', text: '45m Reading', notes: 'If you miss a daily frequently, it will turn darker shades of orange and red. The redder the task is, the more experience and gold it grants for success and the more damage you take for failure. This encourages you to focus on your shortcomings, the reds.', value: -10, completed: false, repeat: repeat }
|
||||
|
||||
{type: 'todo', text: 'Call Mom', notes: 'While not completing a to-do in a set period of time will not hurt you, they will gradually change from yellow to red, thus becoming more valuable. This will encourage you to wrap up stale To-Dos.', value: -3, completed: false }
|
||||
|
||||
{type: 'reward', text: '1 Episode of Game of Thrones', notes: 'Custom rewards can come in many forms. Some people will hold off watching their favorite show unless they have the gold to pay for it.', value: 20 }
|
||||
{type: 'reward', text: 'Cake', notes: 'Other people just want to enjoy a nice piece of cake. Try to create rewards that will motivate you best.', value: 10 }
|
||||
]
|
||||
|
||||
defaultTags = [
|
||||
{name: 'morning'}
|
||||
{name: 'afternoon'}
|
||||
{name: 'evening'}
|
||||
]
|
||||
|
||||
for task in defaultTasks
|
||||
guid = task.id = uuid()
|
||||
newUser["#{task.type}s"].push task
|
||||
|
||||
for tag in defaultTags
|
||||
tag.id = uuid()
|
||||
newUser.tags.push tag
|
||||
|
||||
newUser
|
||||
|
||||
percent: (x,y) ->
|
||||
x=1 if x==0
|
||||
Math.round(x/y*100)
|
||||
|
||||
###
|
||||
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, obj) ->
|
||||
return if ~path.indexOf('undefined')
|
||||
try
|
||||
arr = path.split('.')
|
||||
_.reduce arr, (curr, next, index) ->
|
||||
if (arr.length - 1) == index
|
||||
curr[next] = val
|
||||
(curr[next] ?= {})
|
||||
, obj
|
||||
catch err
|
||||
console.error {err, path, val, _id:obj._id}
|
||||
|
||||
dotGet: (path, obj) ->
|
||||
return undefined if ~path.indexOf('undefined')
|
||||
try
|
||||
_.reduce path.split('.'), ((curr, next) -> curr?[next]), obj
|
||||
catch err
|
||||
console.error {err, path, val, _id:obj._id}
|
||||
|
||||
daysSince: daysSince
|
||||
startOfWeek: startOfWeek
|
||||
startOfDay: startOfDay
|
||||
|
||||
shouldDo: shouldDo
|
||||
|
||||
###
|
||||
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
|
||||
|
||||
###
|
||||
Remove whitespace #FIXME are we using this anywwhere? Should we be?
|
||||
###
|
||||
removeWhitespace: (str) ->
|
||||
return '' unless str
|
||||
str.replace /\s/g, ''
|
||||
|
||||
###
|
||||
Generate the username, since it can be one of many things: their username, their facebook fullname, their manually-set profile name
|
||||
###
|
||||
username: (auth, override) ->
|
||||
#some people define custom profile name in Avatar -> Profile
|
||||
return override if override
|
||||
|
||||
if auth?.facebook?.displayName?
|
||||
auth.facebook.displayName
|
||||
else if auth?.facebook?
|
||||
fb = auth.facebook
|
||||
if fb._raw then "#{fb.name.givenName} #{fb.name.familyName}" else fb.name
|
||||
else if auth?.local?
|
||||
auth.local.username
|
||||
else
|
||||
'Anonymous'
|
||||
|
||||
###
|
||||
Encode the download link for .ics iCal file
|
||||
###
|
||||
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
|
||||
###
|
||||
gold: (num) ->
|
||||
if num
|
||||
return Math.floor num
|
||||
else
|
||||
return "0"
|
||||
|
||||
###
|
||||
Silver amount from their money
|
||||
###
|
||||
silver: (num) ->
|
||||
if num
|
||||
("0" + Math.floor (num - Math.floor(num))*100).slice -2
|
||||
else
|
||||
return "00"
|
||||
|
||||
###
|
||||
Task classes given everything about the class
|
||||
###
|
||||
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 !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
|
||||
###
|
||||
friendlyTimestamp: (timestamp) -> moment(timestamp).format('MM/DD h:mm:ss a')
|
||||
|
||||
###
|
||||
Does user have new chat messages?
|
||||
###
|
||||
newChatMessages: (messages, lastMessageSeen) ->
|
||||
return false unless messages?.length > 0
|
||||
messages?[0] and (messages[0].id != lastMessageSeen)
|
||||
|
||||
###
|
||||
are any tags active?
|
||||
###
|
||||
noTags: (tags) -> _.isEmpty(tags) or _.isEmpty(_.filter( tags, (t) -> t ) )
|
||||
|
||||
###
|
||||
Are there tags applied?
|
||||
###
|
||||
appliedTags: (userTags, taskTags) ->
|
||||
arr = []
|
||||
_.each userTags, (t) ->
|
||||
return unless t?
|
||||
arr.push(t.name) if taskTags?[t.id]
|
||||
arr.join(', ')
|
||||
|
||||
###
|
||||
User stats
|
||||
###
|
||||
|
||||
userStr: (level) ->
|
||||
(level-1) / 2
|
||||
|
||||
totalStr: (level, weapon=0) ->
|
||||
return 'FIXME'
|
||||
str = (level-1) / 2
|
||||
(str + items.getItem('weapon', weapon).strength)
|
||||
|
||||
userDef: (level) ->
|
||||
(level-1) / 2
|
||||
|
||||
totalDef: (level, armor=0, head=0, shield=0) ->
|
||||
return 'FIXME'
|
||||
totalDef =
|
||||
(level - 1) / 2 + # defense
|
||||
items.getItem('armor', armor).defense +
|
||||
items.getItem('head', head).defense +
|
||||
items.getItem('shield', shield).defense
|
||||
return totalDef
|
||||
|
||||
itemText: (type, item=0) ->
|
||||
return 'FIXME'
|
||||
items.getItem(type, item).text
|
||||
|
||||
itemStat: (type, item=0) ->
|
||||
return 'FIXME'
|
||||
i = items.getItem(type, item)
|
||||
if type is 'weapon' then i.strength else i.defense
|
||||
|
||||
countPets: (originalCount, pets) ->
|
||||
count = if originalCount? then originalCount else _.size(pets)
|
||||
for pet of items.items.specialPets
|
||||
count-- if pets[pet]
|
||||
count
|
||||
|
||||
###
|
||||
----------------------------------------------------------------------
|
||||
Derby-specific helpers. Will remove after the rewrite, need them here for now
|
||||
----------------------------------------------------------------------
|
||||
###
|
||||
|
||||
###
|
||||
Make sure model.get() returns all properties, see https://github.com/codeparty/racer/issues/116
|
||||
###
|
||||
hydrate: (spec) ->
|
||||
if _.isObject(spec) and !_.isArray(spec)
|
||||
hydrated = {}
|
||||
keys = _.keys(spec).concat(_.keys(spec.__proto__))
|
||||
keys.forEach (k) => hydrated[k] = @hydrate(spec[k])
|
||||
hydrated
|
||||
else spec
|
||||
910
script/index.coffee
Normal file
910
script/index.coffee
Normal file
|
|
@ -0,0 +1,910 @@
|
|||
moment = require('moment')
|
||||
_ = require('lodash')
|
||||
content = require('./content.coffee')
|
||||
|
||||
XP = 15
|
||||
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
|
||||
|
||||
randomDrop = (user, 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 *= api.priorityValue(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++
|
||||
|
||||
###
|
||||
------------------------------------------------------
|
||||
Scoring
|
||||
------------------------------------------------------
|
||||
###
|
||||
|
||||
api.priorityValue = (priority = '!') ->
|
||||
switch priority
|
||||
when '!' then 1
|
||||
when '!!' then 1.5
|
||||
when '!!!' then 2
|
||||
else 1
|
||||
|
||||
api.tnl = (level) ->
|
||||
if level >= 100
|
||||
value = 0
|
||||
else
|
||||
value = Math.round(((Math.pow(level, 2) * 0.25) + (10 * level) + 139.75) / 10) * 10
|
||||
# round to nearest 10
|
||||
return value
|
||||
|
||||
###
|
||||
Calculates Exp modificaiton based on level and weapon strength
|
||||
{value} task.value for exp gain
|
||||
{weaponStrength) weapon strength
|
||||
{level} current user level
|
||||
{priority} user-defined priority multiplier
|
||||
###
|
||||
api.expModifier = (value, weaponStr, level, priority = '!') ->
|
||||
str = (level - 1) / 2
|
||||
# ultimately get this from user
|
||||
totalStr = (str + weaponStr) / 100
|
||||
strMod = 1 + totalStr
|
||||
exp = value * XP * strMod * api.priorityValue(priority)
|
||||
return Math.round(exp)
|
||||
|
||||
###
|
||||
Calculates HP modification based on level and armor defence
|
||||
{value} task.value for hp loss
|
||||
{armorDefense} defense from armor
|
||||
{helmDefense} defense from helm
|
||||
{level} current user level
|
||||
{priority} user-defined priority multiplier
|
||||
###
|
||||
api.hpModifier = (value, armorDef, helmDef, shieldDef, level, priority = '!') ->
|
||||
def = (level - 1) / 2
|
||||
# ultimately get this from user?
|
||||
totalDef = (def + armorDef + helmDef + shieldDef) / 100
|
||||
#ultimate get this from user
|
||||
defMod = 1 - totalDef
|
||||
hp = value * HP * defMod * api.priorityValue(priority)
|
||||
return Math.round(hp * 10) / 10
|
||||
# round to 1dp
|
||||
|
||||
###
|
||||
Future use
|
||||
{priority} user-defined priority multiplier
|
||||
###
|
||||
api.gpModifier = (value, modifier, priority = '!', streak, user) ->
|
||||
val = value * modifier * api.priorityValue(priority)
|
||||
if streak and user
|
||||
streakBonus = streak / 100 + 1 # eg, 1-day streak is 1.1, 2-day is 1.2, etc
|
||||
afterStreak = val * streakBonus
|
||||
user._tmp.streakBonus = afterStreak - val if (val > 0) # keep this on-hand for later, so we can notify streak-bonus
|
||||
return afterStreak
|
||||
else
|
||||
return val
|
||||
|
||||
###
|
||||
Calculates the next task.value based on direction
|
||||
Uses a capped inverse log y=.95^x, y>= -5
|
||||
{currentValue} the current value of the task
|
||||
{direction} up or down
|
||||
###
|
||||
api.taskDeltaFormula = (currentValue, direction) ->
|
||||
if currentValue < -47.27 then currentValue = -47.27
|
||||
else if currentValue > 21.27 then currentValue = 21.27
|
||||
delta = Math.pow(0.9747, currentValue)
|
||||
return delta if direction is 'up'
|
||||
return -delta
|
||||
|
||||
###
|
||||
Updates user stats with new stats. Handles death, leveling up, etc
|
||||
{stats} new stats
|
||||
{update} if aggregated changes, pass in userObj as update. otherwise commits will be made immediately
|
||||
###
|
||||
updateStats = (user, newStats) ->
|
||||
# if user is dead, dont do anything
|
||||
return if user.stats.hp <= 0
|
||||
|
||||
if newStats.hp?
|
||||
# Game Over
|
||||
if newStats.hp <= 0
|
||||
user.stats.hp = 0
|
||||
return
|
||||
else
|
||||
user.stats.hp = newStats.hp
|
||||
|
||||
if newStats.exp?
|
||||
tnl = api.tnl(user.stats.lvl)
|
||||
#silent = false
|
||||
# if we're at level 100, turn xp to gold
|
||||
if user.stats.lvl >= 100
|
||||
newStats.gp += newStats.exp / 15
|
||||
newStats.exp = 0
|
||||
user.stats.lvl = 100
|
||||
else
|
||||
# level up & carry-over exp
|
||||
if newStats.exp >= tnl
|
||||
#silent = true # push through the negative xp silently
|
||||
user.stats.exp = newStats.exp # push normal + notification
|
||||
while newStats.exp >= tnl and user.stats.lvl < 100 # keep levelling up
|
||||
newStats.exp -= tnl
|
||||
user.stats.lvl++
|
||||
tnl = api.tnl(user.stats.lvl)
|
||||
if user.stats.lvl == 100
|
||||
newStats.exp = 0
|
||||
user.stats.hp = 50
|
||||
|
||||
user.stats.exp = newStats.exp
|
||||
|
||||
# Set flags when they unlock features
|
||||
user.flags ?= {}
|
||||
if !user.flags.customizationsNotification and (user.stats.exp > 10 or user.stats.lvl > 1)
|
||||
user.flags.customizationsNotification = true
|
||||
if !user.flags.itemsEnabled and user.stats.lvl >= 2
|
||||
user.flags.itemsEnabled = true
|
||||
if !user.flags.partyEnabled and user.stats.lvl >= 3
|
||||
user.flags.partyEnabled = true
|
||||
if !user.flags.dropsEnabled and user.stats.lvl >= 4
|
||||
user.flags.dropsEnabled = true
|
||||
user.items.eggs["Wolf"] = 1
|
||||
if !user.flags.classSelected and user.stats.lvl >= 5
|
||||
user.flags.classSelected
|
||||
|
||||
if newStats.gp?
|
||||
#FIXME what was I doing here? I can't remember, gp isn't defined
|
||||
gp = 0.0 if (!gp? or gp < 0)
|
||||
user.stats.gp = newStats.gp
|
||||
|
||||
|
||||
###
|
||||
------------------------------------------------------
|
||||
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}
|
||||
###
|
||||
api.cron = (user, 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.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.flags.rest 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)
|
||||
api.score(user, task, 'down', {times:scheduleMisses, cron:true}) if scheduleMisses > 0
|
||||
|
||||
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 }
|
||||
api.preenUserHistory(user)
|
||||
user
|
||||
|
||||
###
|
||||
Preen history for users with > 7 history entries
|
||||
This takes an infinite array of single day entries [day day day day day...], and turns it into a condensed array
|
||||
of averages, condensing more the further back in time we go. Eg, 7 entries each for last 7 days; 1 entry each week
|
||||
of this month; 1 entry for each month of this year; 1 entry per previous year: [day*7 week*4 month*12 year*infinite]
|
||||
###
|
||||
preenHistory = (history) ->
|
||||
history = _.filter(history, (h) -> !!h) # discard nulls (corrupted somehow)
|
||||
newHistory = []
|
||||
preen = (amount, groupBy) ->
|
||||
groups = _.chain(history)
|
||||
.groupBy((h) -> moment(h.date).format groupBy) # get date groupings to average against
|
||||
.sortBy((h, k) -> k) # sort by date
|
||||
.value() # turn into an array
|
||||
groups = groups.slice(-amount)
|
||||
groups.pop() # get rid of "this week", "this month", etc (except for case of days)
|
||||
_.each groups, (group) ->
|
||||
newHistory.push
|
||||
date: moment(group[0].date).toDate()
|
||||
#date: moment(group[0].date).format('MM/DD/YYYY') # Use this one when testing
|
||||
value: _.reduce(group, ((m, obj) -> m + 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
|
||||
|
||||
# Registered users with some history
|
||||
api.preenUserHistory = (user, minHistLen = 7) ->
|
||||
_.each user.habits.concat(user.dailys), (task) ->
|
||||
task.history = preenHistory(task.history) if task.history?.length > minHistLen
|
||||
true
|
||||
|
||||
_.defaults user.history, {todos:[], exp: []}
|
||||
user.history.exp = preenHistory(user.history.exp) if user.history.exp.length > minHistLen
|
||||
user.history.todos = preenHistory(user.history.todos) if user.history.todos.length > minHistLen
|
||||
#user.markModified('history')
|
||||
#user.markModified('habits')
|
||||
#user.markModified('dailys')
|
||||
|
||||
|
||||
###
|
||||
------------------------------------------------------
|
||||
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: ''
|
||||
priority: '!'
|
||||
challenge: {}
|
||||
_.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
|
||||
------------------------------------------------------
|
||||
###
|
||||
|
||||
###
|
||||
This wraps the user prototype, giving it functions
|
||||
###
|
||||
api.wrap = (user) ->
|
||||
return if user._wrapped
|
||||
user._wrapped = true
|
||||
|
||||
# TODO Document
|
||||
user.ops =
|
||||
|
||||
update: (req, cb) ->
|
||||
_.each req.body, (v,k) ->
|
||||
user.fns.dotSet(k,v)
|
||||
cb? null, req
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
lostStat = randomVal _.reduce ['str','con','per','int'], ((m,k)->(m[k]=k if user.stats[v];m)), {}
|
||||
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]--
|
||||
cb? null, req
|
||||
|
||||
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) ->
|
||||
user.fns.dotSet "purchased." + p, true
|
||||
else
|
||||
if user.fns.dotGet("purchased." + path) is true
|
||||
user.preferences[path.split(".")[0]] = path.split(".")[1]
|
||||
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")
|
||||
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--
|
||||
cb? null, req
|
||||
|
||||
|
||||
score: (req, cb) ->
|
||||
{id, direction} = req.params # up or down
|
||||
task = user.tasks[id]
|
||||
|
||||
# This is for setting one-time temporary flags, such as streakBonus or itemDropped. Useful for notifying
|
||||
# the API consumer, then cleared afterwards
|
||||
user._tmp = {}
|
||||
|
||||
[gp, hp, exp, lvl] = [+user.stats.gp, +user.stats.hp, +user.stats.exp, ~~user.stats.lvl]
|
||||
[type, value, streak, priority] = [task.type, +task.value, ~~task.streak, task.priority or '!']
|
||||
[times, cron] = [req.query?.times or 1, req.query?.cron or false]
|
||||
|
||||
# If they're trying to purhcase a too-expensive reward, don't allow them to do that.
|
||||
if task.value > user.stats.gp and task.type is 'reward'
|
||||
return cb('Not enough Gold');
|
||||
|
||||
delta = 0
|
||||
calculateDelta = (adjustvalue = true) ->
|
||||
# If multiple days have passed, multiply times days missed
|
||||
_.times times, ->
|
||||
# Each iteration calculate the delta (nextDelta), which is then accumulated in delta
|
||||
# (aka, the total delta). This weirdness won't be necessary when calculating mathematically
|
||||
# rather than iteratively
|
||||
nextDelta = api.taskDeltaFormula(value, direction)
|
||||
value += nextDelta if adjustvalue
|
||||
delta += nextDelta
|
||||
|
||||
addPoints = ->
|
||||
weaponStr = user.fns.getItem('weapon').str
|
||||
exp += api.expModifier(delta, weaponStr, user.stats.lvl, priority) / 2 # /2 hack for now, people leveling too fast
|
||||
if streak
|
||||
gp += api.gpModifier(delta, 1, priority, streak, user)
|
||||
else
|
||||
gp += api.gpModifier(delta, 1, priority)
|
||||
|
||||
subtractPoints = ->
|
||||
armorDef = user.fns.getItem('armor').con
|
||||
headDef = user.fns.getItem('head').con
|
||||
shieldDef = user.fns.getItem('shield').con
|
||||
hp += api.hpModifier(delta, armorDef, headDef, shieldDef, user.stats.lvl, priority)
|
||||
|
||||
switch type
|
||||
when 'habit'
|
||||
calculateDelta()
|
||||
# Add habit value to habit-history (if different)
|
||||
if (delta > 0) then addPoints() else subtractPoints()
|
||||
if task.value != value
|
||||
(task.history ?= []).push { date: +new Date, value: value }
|
||||
|
||||
when 'daily'
|
||||
if cron
|
||||
calculateDelta()
|
||||
subtractPoints()
|
||||
task.streak = 0
|
||||
else
|
||||
calculateDelta()
|
||||
addPoints() # obviously for delta>0, but also a trick to undo accidental checkboxes
|
||||
if direction is 'up'
|
||||
streak = if streak then streak + 1 else 1
|
||||
|
||||
# Give a streak achievement when the streak is a multiple of 21
|
||||
if (streak % 21) is 0
|
||||
user.achievements.streak = if user.achievements.streak then user.achievements.streak + 1 else 1
|
||||
|
||||
else
|
||||
# Remove a streak achievement if streak was a multiple of 21 and the daily was undone
|
||||
if (streak % 21) is 0
|
||||
user.achievements.streak = if user.achievements.streak then user.achievements.streak - 1 else 0
|
||||
|
||||
streak = if streak then streak - 1 else 0
|
||||
task.streak = streak
|
||||
|
||||
when 'todo'
|
||||
if cron
|
||||
calculateDelta()
|
||||
#don't touch stats on cron
|
||||
else
|
||||
calculateDelta()
|
||||
addPoints() # obviously for delta>0, but also a trick to undo accidental checkboxes
|
||||
|
||||
when 'reward'
|
||||
# Don't adjust values for rewards
|
||||
calculateDelta(false)
|
||||
# purchase item
|
||||
gp -= Math.abs(task.value)
|
||||
num = parseFloat(task.value).toFixed(2)
|
||||
# if too expensive, reduce health & zero gp
|
||||
if gp < 0
|
||||
hp += gp
|
||||
# hp - gp difference
|
||||
gp = 0
|
||||
|
||||
task.value = value
|
||||
updateStats user, { hp, exp, gp }
|
||||
|
||||
# Drop system (don't run on the client, as it would only be discarded since ops are sent to the API, not the results)
|
||||
if typeof window is 'undefined'
|
||||
randomDrop(user, {delta, priority, streak}) if direction is 'up'
|
||||
|
||||
cb? null, req
|
||||
return delta
|
||||
|
||||
user.fns =
|
||||
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
|
||||
# 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
|
||||
|
||||
# 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) =>
|
||||
val = user.dotGet(path)
|
||||
m2 +
|
||||
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
|
||||
, 0); m
|
||||
, {})
|
||||
Object.defineProperty user, 'tasks',
|
||||
get: ->
|
||||
tasks = user.habits.concat(user.dailys).concat(user.todos).concat(user.rewards)
|
||||
_.object(_.pluck(tasks, "id"), tasks)
|
||||
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
exports.algos = require('./algos.coffee');
|
||||
exports.items = require('./items.coffee');
|
||||
exports.helpers = require('./helpers.coffee');
|
||||
|
||||
var moment = require('moment');
|
||||
var _ = require('lodash');
|
||||
|
||||
try {
|
||||
window;
|
||||
window.habitrpgShared = exports;
|
||||
window._ = _;
|
||||
window.moment = moment;
|
||||
} catch(e) {}
|
||||
Loading…
Reference in a new issue