mirror of
https://github.com/sudoxnym/habitica.git
synced 2026-07-12 15:05:30 +00:00
Merge branch 'develop' of github.com:HabitRPG/habitrpg into add-flag-to-chat
Conflicts: views/options/social/chat-message.jade
This commit is contained in:
commit
7f10049b37
32 changed files with 431 additions and 155 deletions
|
|
@ -41,5 +41,13 @@
|
|||
"mode":"sandbox",
|
||||
"client_id":"client_id",
|
||||
"client_secret":"client_secret"
|
||||
},
|
||||
"IAP_GOOGLE_KEYDIR": "/path/to/google/public/key/dir/",
|
||||
"LOGGLY": {
|
||||
"enabled": false,
|
||||
"subdomain": "subdomain",
|
||||
"token": "token",
|
||||
"username": "username",
|
||||
"password": "password"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
keys/google/iap-live
Normal file
1
keys/google/iap-live
Normal file
|
|
@ -0,0 +1 @@
|
|||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk6EVccD7D6y5vLmsUxUXVYTMyByq+NjfTzjMBaKCk93fq8Ahn6Ipha1K4oBIvArUj3CsP5Tb/Yf6c6SrEsSnE3eqgKQgeq+fYyfIm+Fb+tFGTttLonZ2Sqemqeyx2PjOG2CVZBXj1+Zr7jb0ocMNYraYUTvklWnpcK6/5ZaZLLent1PGiU9l5jIfRAsxmJH/UpssgpFPnCs+8PRKUALkJATTfVCCF+PJef6mcKAafAArKUEo0lR2/NeaE62Wpii33DLuKDPfMqL3g4EHZTEVE1ezFl/3WPeLlxuQm38hZLpEfEYKp17qzplU+GmWIfjZt6OdtVdonlYxIxNAZLnWEQIDAQAB
|
||||
4
migrations/20141211_NaN_consecutives.js
Normal file
4
migrations/20141211_NaN_consecutives.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
db.users.update({'purchased.plan.consecutive.count':NaN}, {$set:{'purchased.plan.consecutive.count':0}}, {multi:1});
|
||||
db.users.update({'purchased.plan.consecutive.offset':NaN}, {$set:{'purchased.plan.consecutive.offset':0}}, {multi:1});
|
||||
db.users.update({'purchased.plan.consecutive.gemCapExtra':NaN}, {$set:{'purchased.plan.consecutive.gemCapExtra':0}}, {multi:1});
|
||||
db.users.update({'purchased.plan.consecutive.trinkets':NaN}, {$set:{'purchased.plan.consecutive.trinkets':0}}, {multi:1});
|
||||
|
|
@ -26,9 +26,11 @@
|
|||
"grunt-nodemon": "~0.3.0",
|
||||
"habitrpg-shared": "git://github.com/HabitRPG/habitrpg-shared#develop",
|
||||
"icalendar": "git://github.com/lefnire/node-icalendar#master",
|
||||
"in-app-purchase": "^0.2.0",
|
||||
"jade": "~1.7.0",
|
||||
"js2xmlparser": "~0.1.2",
|
||||
"lodash": "~2.4.1",
|
||||
"loggly": "~1.0.8",
|
||||
"method-override": "~2.2.0",
|
||||
"moment": "~2.8.3",
|
||||
"mongoose": "~3.8.17",
|
||||
|
|
@ -61,7 +63,7 @@
|
|||
"www.habitrpg.com"
|
||||
],
|
||||
"engines": {
|
||||
"node": "0.10.x",
|
||||
"node": "0.10.22",
|
||||
"npm": "1.2.x"
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -31,16 +31,17 @@ future re: pets and whatnot, this is just temporary.
|
|||
position: absolute
|
||||
@extend $hrpg-label
|
||||
.avatar-level
|
||||
bottom: 2px
|
||||
right: 2px
|
||||
bottom: 4px
|
||||
right: 4px
|
||||
// when a buff/rebirth exists, add space between the icon and the level
|
||||
.glyphicon-circle-arrow-up
|
||||
padding-right: 4px
|
||||
.glyphicon-plus-sign
|
||||
padding-left: 4px
|
||||
.avatar-name
|
||||
top: 2px
|
||||
left: 2px
|
||||
max-width: 132px
|
||||
top: 4px
|
||||
left: 4px
|
||||
opacity: 0 // Hide username by default
|
||||
transition: opacity 0.2s ease-out
|
||||
|
||||
|
|
@ -83,8 +84,9 @@ future re: pets and whatnot, this is just temporary.
|
|||
width: 50px !important
|
||||
.addthis_native_toolbox
|
||||
position: absolute
|
||||
top:2px
|
||||
right:2px
|
||||
bottom:4px
|
||||
left:4px
|
||||
z-index: 2
|
||||
opacity:0
|
||||
&:hover .addthis_native_toolbox
|
||||
opacity:1
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@
|
|||
markdown, span.time
|
||||
padding: 0 3px
|
||||
line-height: 1.618em
|
||||
|
||||
.long-title .tooltip
|
||||
white-space: normal
|
||||
|
||||
span.time
|
||||
white-space: nowrap
|
||||
|
|
|
|||
|
|
@ -32,4 +32,33 @@
|
|||
visibility: hidden
|
||||
|
||||
.transparent
|
||||
opacity: 0.5
|
||||
opacity: 0.5
|
||||
|
||||
// Allow block element to be centered
|
||||
.col-centered
|
||||
margin-left: auto
|
||||
margin-right: auto
|
||||
|
||||
// .pull-right & .pull-left for specific screen sizes
|
||||
// Follows conventions for col-x-SIZE classes
|
||||
|
||||
@media (min-width: 768px)
|
||||
.pull-left-sm
|
||||
float: left
|
||||
|
||||
.pull-right-sm
|
||||
float: right
|
||||
|
||||
@media (min-width: 992px)
|
||||
.pull-left-md
|
||||
float: left
|
||||
|
||||
.pull-right-md
|
||||
float: right
|
||||
|
||||
@media (min-width: 1200px)
|
||||
.pull-left-lg
|
||||
float: left
|
||||
|
||||
.pull-right-lg
|
||||
float: right
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
.quest_collected_true
|
||||
color: #ccc
|
||||
text-decoration:line-through
|
||||
text-decoration:line-through
|
||||
|
||||
quest-rewards
|
||||
hr
|
||||
clear:both
|
||||
|
|
|
|||
|
|
@ -118,6 +118,8 @@ for $stage in $stages
|
|||
|
||||
.habit-wide .task-text
|
||||
padding-left: 7em
|
||||
.habit-narrow .task-text
|
||||
padding-left: 0.75em
|
||||
|
||||
// when a task is being dragged
|
||||
.task.ui-sortable-helper
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ window.habitrpg = angular.module('habitrpg',
|
|||
|
||||
// @see https://github.com/angular-ui/ui-router/issues/110 and https://github.com/HabitRPG/habitrpg/issues/1705
|
||||
// temporary hack until they have a better solution
|
||||
.value('$anchorScroll', angular.noop)
|
||||
|
||||
.constant("API_URL", "")
|
||||
.constant("STORAGE_USER_ID", 'habitrpg-user')
|
||||
|
|
@ -185,6 +184,10 @@ window.habitrpg = angular.module('habitrpg',
|
|||
url: '/timetravelers',
|
||||
templateUrl: "partials/options.inventory.timetravelers.html"
|
||||
})
|
||||
.state('options.inventory.seasonalshop', {
|
||||
url: '/seasonalshop',
|
||||
templateUrl: "partials/options.inventory.seasonalshop.html"
|
||||
})
|
||||
|
||||
// Options > Settings
|
||||
.state('options.settings', {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
//$.getScript("//s7.addthis.com/js/300/addthis_widget.js#pubid=ra-5016f6cc44ad68a4"); //FIXME why isn't this working when here? instead it's now in <head>
|
||||
var addthisServices = 'facebook,twitter,googleplus,tumblr,'+window.env.BASE_URL.replace('https://','').replace('http://','');
|
||||
window.addthis_config = {
|
||||
ui_click: true,
|
||||
services_custom:{
|
||||
name: "Download",
|
||||
url: window.env.BASE_URL+"/export/avatar-"+User.user._id+".png",
|
||||
|
|
|
|||
|
|
@ -148,21 +148,19 @@ api.loginSocial = function(req, res, next) {
|
|||
network = req.body.network;
|
||||
if (network!=='facebook')
|
||||
return res.json(401, {err:"Only Facebook supported currently."});
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
async.auto({
|
||||
profile: function (cb) {
|
||||
passport._strategies[network].userProfile(access_token, cb);
|
||||
},
|
||||
function(profile, cb) {
|
||||
var q = {};q['auth.'+network+'.id'] = profile.id;
|
||||
User.findOne(q, {_id:1, apiToken:1, auth:1}, function(err, user){
|
||||
if (err) return cb(err);
|
||||
cb(null, {user:user, profile:profile});
|
||||
});
|
||||
},
|
||||
function(data, cb){
|
||||
if (data.user) return cb(null, data.user);
|
||||
user: ['profile', function (cb, results) {
|
||||
var q = {};
|
||||
q['auth.' + network + '.id'] = results.profile.id;
|
||||
User.findOne(q, {_id: 1, apiToken: 1, auth: 1}, cb);
|
||||
}],
|
||||
register: ['profile', 'user', function (cb, results) {
|
||||
if (results.user) return cb(null, results.user);
|
||||
// Create new user
|
||||
var prof = data.profile;
|
||||
var prof = results.profile;
|
||||
var user = {
|
||||
preferences: {
|
||||
language: req.language // User language detected from browser, not saved
|
||||
|
|
@ -175,15 +173,16 @@ api.loginSocial = function(req, res, next) {
|
|||
user = new User(user);
|
||||
user.save(cb);
|
||||
|
||||
if(isProd && prof.emails && prof.emails[0] && prof.emails[0].value){
|
||||
utils.txnEmail({name:prof.displayName || prof.username, email:prof.emails[0].value}, 'welcome');
|
||||
if (isProd && prof.emails && prof.emails[0] && prof.emails[0].value) {
|
||||
utils.txnEmail({name: prof.displayName || prof.username, email: prof.emails[0].value}, 'welcome');
|
||||
}
|
||||
ga.event('register', network).send();
|
||||
}
|
||||
], function(err, user){
|
||||
}]
|
||||
}, function(err, results){
|
||||
if (err) return res.json(401, {err: err.toString ? err.toString() : err});
|
||||
if (user.auth.blocked) return res.json(401, accountSuspended(user._id));
|
||||
return res.json(200, {id: user.id, token:user.apiToken});
|
||||
var acct = results.register[0] ? results.register[0] : results.register;
|
||||
if (acct.auth.blocked) return res.json(401, accountSuspended(acct._id));
|
||||
return res.json(200, {id:acct._id, token:acct.apiToken});
|
||||
})
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -147,69 +147,63 @@ api.getMember = function(req, res, next) {
|
|||
// CREATE
|
||||
api.create = function(req, res, next){
|
||||
var user = res.locals.user;
|
||||
var group, chal;
|
||||
|
||||
// First, make sure they've selected a legit group, and store it for later
|
||||
var waterfall = [
|
||||
function(cb){
|
||||
Group.findById(req.body.group).exec(cb);
|
||||
async.auto({
|
||||
get_group: function(cb){
|
||||
var q = {_id:req.body.group};
|
||||
if (req.body.group!='habitrpg') q.members = {$in:[user._id]}; // make sure they're a member of the group
|
||||
Group.findOne(q, cb);
|
||||
},
|
||||
function(_group, cb){
|
||||
if (!_group) return cb("Group." + req.body.group + " not found");
|
||||
group = _group;
|
||||
cb(null);
|
||||
}
|
||||
];
|
||||
save_chal: ['get_group', function(cb, results){
|
||||
var group = results.get_group,
|
||||
prize = +req.body.prize;
|
||||
if (!group)
|
||||
return cb({code:404, err:"Group." + req.body.group + " not found"});
|
||||
if (group.leaderOnly && group.leaderOnly.challenges && group.leader !== user._id)
|
||||
return cb({code:401, err: "Only the group leader can create challenges"});
|
||||
// If they're adding a prize, do some validation
|
||||
if (prize < 0)
|
||||
return cb({code:401, err: 'Challenge prize must be >= 0'});
|
||||
if (req.body.group=='habitrpg' && prize < 1)
|
||||
return cb({code:401, err: 'Prize must be at least 1 Gem for public challenges.'});
|
||||
if (prize > 0) {
|
||||
var groupBalance = ((group.balance && group.leader==user._id) ? group.balance : 0);
|
||||
var prizeCost = prize/4; // I really should have stored user.balance as gems rather than dollars... stupid...
|
||||
if (prizeCost > user.balance + groupBalance)
|
||||
return cb("You can't afford this prize. Purchase more gems or lower the prize amount.")
|
||||
|
||||
// If they're adding a prize, do some validation
|
||||
if (+req.body.prize < 0) return res.json(401, {err: 'Challenge prize must be >= 0'});
|
||||
if (req.body.group=='habitrpg' && +req.body.prize < 1) return res.json(401, {err: 'Prize must be at least 1 Gem for public challenges.'});
|
||||
if (+req.body.prize > 0) {
|
||||
waterfall.push(function(cb){
|
||||
var groupBalance = ((group.balance && group.leader==user._id) ? group.balance : 0);
|
||||
var prizeCost = req.body.prize/4; // I really should have stored user.balance as gems rather than dollars... stupid...
|
||||
if (prizeCost > user.balance + groupBalance)
|
||||
return cb("You can't afford this prize. Purchase more gems or lower the prize amount.")
|
||||
|
||||
if (groupBalance >= prizeCost) {
|
||||
// Group pays for all of prize
|
||||
group.balance -= prizeCost;
|
||||
} else if (groupBalance > 0) {
|
||||
// User pays remainder of prize cost after group
|
||||
var remainder = prizeCost - group.balance;
|
||||
group.balance = 0;
|
||||
user.balance -= remainder;
|
||||
} else {
|
||||
// User pays for all of prize
|
||||
user.balance -= prizeCost;
|
||||
}
|
||||
cb(null);
|
||||
});
|
||||
}
|
||||
|
||||
waterfall = waterfall.concat([
|
||||
function(cb) { // if we're dealing with prize above, arguemnts will be `group, numRows, cb` - else `cb`
|
||||
if (groupBalance >= prizeCost) {
|
||||
// Group pays for all of prize
|
||||
group.balance -= prizeCost;
|
||||
} else if (groupBalance > 0) {
|
||||
// User pays remainder of prize cost after group
|
||||
var remainder = prizeCost - group.balance;
|
||||
group.balance = 0;
|
||||
user.balance -= remainder;
|
||||
} else {
|
||||
// User pays for all of prize
|
||||
user.balance -= prizeCost;
|
||||
}
|
||||
}
|
||||
req.body.leader = user._id;
|
||||
req.body.official = user.contributor.admin && req.body.official;
|
||||
var chal = new Challenge(req.body); // FIXME sanitize
|
||||
chal.members.push(user._id);
|
||||
chal.save(cb)
|
||||
},
|
||||
function(_chal, num, cb){
|
||||
chal = _chal;
|
||||
group.challenges.push(chal._id);
|
||||
group.save(cb);
|
||||
},
|
||||
function(_group, num, cb) {
|
||||
chal.save(cb);
|
||||
}],
|
||||
save_group: ['save_chal', function(cb, results){
|
||||
results.get_group.challenges.push(results.save_chal[0]._id);
|
||||
results.get_group.save(cb);
|
||||
}],
|
||||
sync_user: ['save_group', function(cb, results){
|
||||
// Auto-join creator to challenge (see members.push above)
|
||||
chal.syncToUser(user, cb);
|
||||
}
|
||||
]);
|
||||
async.waterfall(waterfall, function(err){
|
||||
if (err) return next(err);
|
||||
res.json(chal);
|
||||
user = group = chal = null;
|
||||
});
|
||||
results.save_chal[0].syncToUser(user, cb);
|
||||
}]
|
||||
}, function(err, results){
|
||||
if (err) return err.code? res.json(err.code, err) : next(err);
|
||||
return res.json(results.save_chal[0]);
|
||||
user = null;
|
||||
})
|
||||
}
|
||||
|
||||
// UPDATE
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ api.update = function(req, res, next) {
|
|||
if(group.leader !== user._id)
|
||||
return res.json(401, {err: "Only the group leader can update the group!"});
|
||||
|
||||
'name description logo logo leaderMessage leader'.split(' ').forEach(function(attr){
|
||||
'name description logo logo leaderMessage leader leaderOnly'.split(' ').forEach(function(attr){
|
||||
group[attr] = req.body[attr];
|
||||
});
|
||||
|
||||
|
|
|
|||
129
src/controllers/payments/iap.js
Normal file
129
src/controllers/payments/iap.js
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
var iap = require('in-app-purchase');
|
||||
var async = require('async');
|
||||
var payments = require('./index');
|
||||
var nconf = require('nconf');
|
||||
|
||||
var inAppPurchase = require('in-app-purchase');
|
||||
inAppPurchase.config({
|
||||
// this is the path to the directory containing iap-sanbox/iap-live files
|
||||
googlePublicKeyPath: nconf.get("IAP_GOOGLE_KEYDIR")
|
||||
});
|
||||
|
||||
// Validation ERROR Codes
|
||||
var INVALID_PAYLOAD = 6778001;
|
||||
var CONNECTION_FAILED = 6778002;
|
||||
var PURCHASE_EXPIRED = 6778003;
|
||||
|
||||
exports.androidVerify = function(req, res, next) {
|
||||
var iapBody = req.body;
|
||||
var user = res.locals.user;
|
||||
|
||||
iap.setup(function (error) {
|
||||
if (error) {
|
||||
var resObj = {
|
||||
ok: false,
|
||||
data: 'IAP Error'
|
||||
};
|
||||
|
||||
console.error('IAP Setup ERROR');
|
||||
console.error(error);
|
||||
|
||||
res.json(resObj);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
google receipt must be provided as an object
|
||||
{
|
||||
"data": "{stringified data object}",
|
||||
"signature": "signature from google"
|
||||
}
|
||||
*/
|
||||
var testObj = {
|
||||
data: iapBody.transaction.receipt,
|
||||
signature: iapBody.transaction.signature
|
||||
};
|
||||
|
||||
// iap is ready
|
||||
iap.validate(iap.GOOGLE, testObj, function (err, googleRes) {
|
||||
if (err) {
|
||||
var resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: err.toString()
|
||||
}
|
||||
};
|
||||
|
||||
res.json(resObj);
|
||||
console.error(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (iap.isValidated(googleRes)) {
|
||||
var resObj = {
|
||||
ok: true,
|
||||
data: googleRes
|
||||
};
|
||||
|
||||
payments.buyGems({user:user, paymentMethod:'IAP GooglePlay'});
|
||||
|
||||
// yay good!
|
||||
res.json(resObj);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
exports.iosVerify = function(req, res, next) {
|
||||
console.info(req.body);
|
||||
|
||||
var iapBody = req.body;
|
||||
var user = res.locals.user;
|
||||
|
||||
iap.setup(function (error) {
|
||||
if (error) {
|
||||
var resObj = {
|
||||
ok: false,
|
||||
data: 'IAP Error'
|
||||
};
|
||||
|
||||
console.error('IAP Setup ERROR');
|
||||
console.error(error);
|
||||
|
||||
res.json(resObj);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// iap is ready
|
||||
iap.validate(iap.APPLE, iapBody.transaction.receipt, function (err, appleRes) {
|
||||
if (err) {
|
||||
var resObj = {
|
||||
ok: false,
|
||||
data: {
|
||||
code: INVALID_PAYLOAD,
|
||||
message: err.toString()
|
||||
}
|
||||
};
|
||||
|
||||
res.json(resObj);
|
||||
console.error(err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (iap.isValidated(appleRes)) {
|
||||
var resObj = {
|
||||
ok: true,
|
||||
data: appleRes
|
||||
};
|
||||
|
||||
payments.buyGems({user:user, paymentMethod:'IAP AppleStore'});
|
||||
|
||||
// yay good!
|
||||
res.json(resObj);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
@ -7,9 +7,9 @@ var moment = require('moment');
|
|||
var isProduction = nconf.get("NODE_ENV") === "production";
|
||||
var stripe = require('./stripe');
|
||||
var paypal = require('./paypal');
|
||||
var User = require('mongoose').model('User');
|
||||
var members = require('../members')
|
||||
var async = require('async');
|
||||
var iap = require('./iap');
|
||||
|
||||
function revealMysteryItems(user) {
|
||||
_.each(shared.content.gear.flat, function(item) {
|
||||
|
|
@ -123,4 +123,7 @@ exports.paypalSubscribeSuccess = paypal.executeBillingAgreement;
|
|||
exports.paypalSubscribeCancel = paypal.cancelSubscription;
|
||||
exports.paypalCheckout = paypal.createPayment;
|
||||
exports.paypalCheckoutSuccess = paypal.executePayment;
|
||||
exports.paypalIPN = paypal.ipn;
|
||||
exports.paypalIPN = paypal.ipn;
|
||||
|
||||
exports.iapAndroidVerify = iap.androidVerify;
|
||||
exports.iapIosVerify = iap.iosVerify;
|
||||
|
|
|
|||
|
|
@ -257,37 +257,45 @@ api.update = function(req, res, next) {
|
|||
};
|
||||
|
||||
api.cron = function(req, res, next) {
|
||||
var user = res.locals.user,
|
||||
progress = user.fns.cron(),
|
||||
ranCron = user.isModified(),
|
||||
quest = shared.content.quests[user.party.quest.key];
|
||||
try{
|
||||
var user = res.locals.user,
|
||||
progress = user.fns.cron(),
|
||||
ranCron = user.isModified(),
|
||||
quest = shared.content.quests[user.party.quest.key];
|
||||
|
||||
if (ranCron) res.locals.wasModified = true;
|
||||
if (!ranCron) return next(null,user);
|
||||
Group.tavernBoss(user,progress);
|
||||
if (!quest) return user.save(next);
|
||||
|
||||
if (ranCron) res.locals.wasModified = true;
|
||||
if (!ranCron) return next(null,user);
|
||||
Group.tavernBoss(user,progress);
|
||||
if (!quest) return user.save(next);
|
||||
|
||||
// If user is on a quest, roll for boss & player, or handle collections
|
||||
// FIXME this saves user, runs db updates, loads user. Is there a better way to handle this?
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
user.save(cb); // make sure to save the cron effects
|
||||
},
|
||||
function(saved, count, cb){
|
||||
var type = quest.boss ? 'boss' : 'collect';
|
||||
Group[type+'Quest'](user,progress,cb);
|
||||
},
|
||||
function(){
|
||||
var cb = arguments[arguments.length-1];
|
||||
// User has been updated in boss-grapple, reload
|
||||
User.findById(user._id, cb);
|
||||
}
|
||||
], function(err, saved) {
|
||||
res.locals.user = saved;
|
||||
next(err,saved);
|
||||
user = progress = quest = null;
|
||||
});
|
||||
// If user is on a quest, roll for boss & player, or handle collections
|
||||
// FIXME this saves user, runs db updates, loads user. Is there a better way to handle this?
|
||||
async.waterfall([
|
||||
function(cb){
|
||||
user.save(cb); // make sure to save the cron effects
|
||||
},
|
||||
function(saved, count, cb){
|
||||
var type = quest.boss ? 'boss' : 'collect';
|
||||
Group[type+'Quest'](user,progress,cb);
|
||||
},
|
||||
function(){
|
||||
var cb = arguments[arguments.length-1];
|
||||
// User has been updated in boss-grapple, reload
|
||||
User.findById(user._id, cb);
|
||||
}
|
||||
], function(err, saved) {
|
||||
if(err) logging.loggly({error: "Cron caught", stack: err.stack || err});
|
||||
res.locals.user = saved;
|
||||
next(err,saved);
|
||||
user = progress = quest = null;
|
||||
});
|
||||
}catch(e){
|
||||
logging.loggly({
|
||||
error: "Cron uncaught",
|
||||
stack: e.stack || e
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,23 @@ var winston = require('winston');
|
|||
require('winston-mail').Mail;
|
||||
require('winston-newrelic');
|
||||
|
||||
var logger;
|
||||
var logger, loggly;
|
||||
|
||||
if (nconf.get('LOGGLY:enabled')){
|
||||
loggly = require('loggly').createClient({
|
||||
token: nconf.get('LOGGLY:token'),
|
||||
subdomain: nconf.get('LOGGLY:subdomain'),
|
||||
auth: {
|
||||
username: nconf.get('LOGGLY:username'),
|
||||
password: nconf.get('LOGGLY:password')
|
||||
},
|
||||
//
|
||||
// Optional: Tag to send with EVERY log message
|
||||
//
|
||||
tags: [('heroku-'+nconf.get('BASE_URL'))],
|
||||
json: true
|
||||
});
|
||||
}
|
||||
|
||||
if (logger == null) {
|
||||
logger = new (winston.Logger)({});
|
||||
|
|
@ -49,3 +65,8 @@ module.exports.error = function(/* variable args */) {
|
|||
if (logger)
|
||||
logger.error.apply(logger, arguments);
|
||||
};
|
||||
|
||||
module.exports.loggly = function(/* variable args */){
|
||||
if (loggly)
|
||||
loggly.log.apply(loggly, arguments);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -75,6 +75,13 @@ module.exports.errorHandler = function(err, req, res, next) {
|
|||
"\n\nbody: " + JSON.stringify(req.body) +
|
||||
(res.locals.ops ? "\n\ncompleted ops: " + JSON.stringify(res.locals.ops) : "");
|
||||
logging.error(stack);
|
||||
logging.loggly({
|
||||
error: "Uncaught error",
|
||||
stack: (err.stack || err.message || err),
|
||||
body: req.body, headers: req.header,
|
||||
auth: (req.headers['x-api-user'] + ' | ' + req.headers['x-api-key']),
|
||||
originalUrl: req.originalUrl
|
||||
});
|
||||
var message = err.message ? err.message : err;
|
||||
message = (message.length < 200) ? message : message.substring(0,100) + message.substring(message.length-100,message.length);
|
||||
res.json(500,{err:message}); //res.end(err.message);
|
||||
|
|
@ -173,12 +180,9 @@ module.exports.locals = function(req, res, next) {
|
|||
language.momentLang = ((!isStaticPage && i18n.momentLangs[language.code]) || undefined);
|
||||
|
||||
var tavern = require('./models/group').tavern;
|
||||
res.locals.habitrpg = {
|
||||
NODE_ENV: nconf.get('NODE_ENV'),
|
||||
BASE_URL: nconf.get('BASE_URL'),
|
||||
GA_ID: nconf.get("GA_ID"),
|
||||
var envVars = _.pick(nconf.get(), 'NODE_ENV BASE_URL GA_ID STRIPE_PUB_KEY FACEBOOK_KEY'.split(' '));
|
||||
res.locals.habitrpg = _.merge(envVars, {
|
||||
IS_MOBILE: /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(req.header('User-Agent')),
|
||||
STRIPE_PUB_KEY: nconf.get('STRIPE_PUB_KEY'),
|
||||
getManifestFiles: getManifestFiles,
|
||||
getBuildUrl: getBuildUrl,
|
||||
avalaibleLanguages: i18n.avalaibleLanguages,
|
||||
|
|
@ -193,11 +197,9 @@ module.exports.locals = function(req, res, next) {
|
|||
siteVersion: siteVersion,
|
||||
Content: shared.content,
|
||||
mods: require('./models/user').mods,
|
||||
FACEBOOK_KEY: nconf.get('FACEBOOK_KEY'),
|
||||
|
||||
tavern: tavern, // for world boss
|
||||
worldDmg: (tavern && tavern.quest && tavern.quest.extra && tavern.quest.extra.worldDmg) || {}
|
||||
};
|
||||
});
|
||||
|
||||
// Put query-string party invitations into session to be handled later
|
||||
try{
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ var GroupSchema = new Schema({
|
|||
# id: String
|
||||
# }]
|
||||
*/
|
||||
|
||||
leaderOnly: { // restrict group actions to leader (members can't do them)
|
||||
challenges: {type:Boolean, 'default':false},
|
||||
//invites: {type:Boolean, 'default':false}
|
||||
},
|
||||
memberCount: {type: Number, 'default': 0},
|
||||
challengeCount: {type: Number, 'default': 0},
|
||||
balance: Number,
|
||||
|
|
|
|||
|
|
@ -17,4 +17,7 @@ router.post("/stripe/subscribe/edit", auth.auth, i18n.getUserLanguage, payments.
|
|||
//router.get("/stripe/subscribe", auth.authWithUrl, i18n.getUserLanguage, payments.stripeSubscribe); // checkout route is used (above) with ?plan= instead
|
||||
router.get("/stripe/subscribe/cancel", auth.authWithUrl, i18n.getUserLanguage, payments.stripeSubscribeCancel);
|
||||
|
||||
router.post("/iap/android/verify", auth.authWithUrl, /*i18n.getUserLanguage, */payments.iapAndroidVerify);
|
||||
router.post("/iap/ios/verify", /*auth.authWithUrl, i18n.getUserLanguage, */ payments.iapIosVerify);
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -17,7 +17,10 @@ script(type='text/ng-template', id='partials/options.inventory.html')
|
|||
=env.t('equipment')
|
||||
li.equipment-tab(ng-class="{ active: $state.includes('options.inventory.timetravelers') }")
|
||||
a(ui-sref='options.inventory.timetravelers')
|
||||
| Time Travelers
|
||||
=env.t('timeTravelers')
|
||||
li.equipment-tab(ng-class="{ active: $state.includes('options.inventory.seasonalshop') }")
|
||||
a(ui-sref='options.inventory.seasonalshop')
|
||||
=env.t('seasonalShop')
|
||||
|
||||
.tab-content
|
||||
.tab-pane.active
|
||||
|
|
|
|||
|
|
@ -19,6 +19,18 @@ script(type='text/ng-template', id='partials/options.inventory.equipment.html')
|
|||
div(ng-repeat='item in gear[klass]')
|
||||
button.customize-option(popover='{{::item.notes()}}', popover-title='{{::item.text()}}', popover-trigger='mouseenter', popover-placement='right', ng-click='user.ops.equip({params:{type:"costume", key:item.key}})', class='shop_{{::item.key}}', ng-class='{selectableInventory: user.items.gear.costume[item.type] == item.key}')
|
||||
|
||||
script(type='text/ng-template', id='partials/options.inventory.seasonalshop.html')
|
||||
.container-fluid
|
||||
.stable.row
|
||||
.col-md-2
|
||||
.seasonalshop_closed
|
||||
.col-md-10
|
||||
.popover.static-popover.fade.right.in
|
||||
.arrow
|
||||
h3.popover-title!=env.t('seasonalShopClosedTitle', {linkStart:"<a href='http://blog.habitrpg.com/who' target='_blank'>", linkEnd: "</a>"})
|
||||
.popover-content
|
||||
p!=env.t('seasonalShopClosedText', {linkStart: "<a href='https://habitrpg.wikia.com/wiki/Community#World_Events' target='_blank'>", linkEnd: "</a>"})
|
||||
|
||||
script(type='text/ng-template', id='partials/options.inventory.timetravelers.html')
|
||||
.container-fluid
|
||||
.stable.row(ng-if='user.purchased.plan.consecutive.trinkets <= 0')
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@ mixin customizeProfile(mobile)
|
|||
if ~['limited','seasonal'].indexOf(status)
|
||||
.label.label-info.pull-right.hint(popover=limited, popover-title=env.t(status+'Edition'), popover-placement='right', popover-trigger='mouseenter')=env.t(status+'Edition')
|
||||
menu(label=env.t(title))
|
||||
+gemCost(2)
|
||||
button.btn.btn-xs(ng-hide='#{status=="disabled"} || #{showPath("user.purchased."+path, colors, "||")}', ng-click='#{unlockPath(path, colors)}')!= env.t('unlockSet',{cost:5}) + ' <span class="Pet_Currency_Gem1x inline-gems"/>'
|
||||
span(ng-hide='#{status=="disabled"} || #{showPath("user.purchased."+path, colors, "&&")}')
|
||||
+gemCost(2)
|
||||
button.btn.btn-xs(ng-click='#{unlockPath(path, colors)}')!= env.t('unlockSet',{cost:5}) + ' <span class="Pet_Currency_Gem1x inline-gems"/>'
|
||||
each color in colors
|
||||
button.customize-option(type='button', class='#{path=="skin" ? "skin_"+color : "customize-option hair hair_bangs_1_"+color}', ng-class='{locked: !user.purchased.#{path}["#{color}"]}', ng-if='#{status!="disabled"} || user.purchased.#{path}["#{color}"]', ng-click='unlock("#{path}.#{color}")')
|
||||
|
||||
|
|
@ -38,8 +39,9 @@ mixin customizeProfile(mobile)
|
|||
|
||||
menu(label=env.t('specialShirts'))
|
||||
- var specialShirts = ['convict', 'cross', 'fire', 'horizon', 'ocean', 'purple', 'rainbow', 'redblue', 'thunder', 'tropical', 'zombie']
|
||||
+gemCost(2)
|
||||
button.btn.btn-xs(ng-hide='#{showPath("user.purchased.shirt", specialShirts, "&&")}', ng-click='#{unlockPath("shirt",specialShirts)}')!= env.t('unlockSet',{cost:5}) + ' <span class="Pet_Currency_Gem1x inline-gems"/>'
|
||||
span(ng-hide='#{showPath("user.purchased.shirt", specialShirts, "&&")}')
|
||||
+gemCost(2)
|
||||
button.btn.btn-xs(ng-click='#{unlockPath("shirt",specialShirts)}')!= env.t('unlockSet',{cost:5}) + ' <span class="Pet_Currency_Gem1x inline-gems"/>'
|
||||
each shirt in specialShirts
|
||||
button.customize-option(type='button', class='{{user.preferences.size}}_shirt_'+shirt, ng-class='{locked: !user.purchased.shirt.'+shirt+'}', ng-click='unlock("shirt.'+shirt+'")')
|
||||
|
||||
|
|
@ -80,8 +82,9 @@ mixin customizeProfile(mobile)
|
|||
// Purchasable hairstyles
|
||||
menu(label=env.t('hairSet1'))
|
||||
- var colors = [2,4,5,6,7,8]
|
||||
+gemCost(2)
|
||||
button.btn.btn-xs(ng-hide='#{showPath("user.purchased.hair", colors, "&&")}', ng-click='#{unlockPath("hair.base",colors)}')!= env.t('unlockSet',{cost:5}) + ' <span class="Pet_Currency_Gem1x inline-gems"/>'
|
||||
span(ng-hide='#{showPath("user.purchased.hair.base", colors, "&&")}')
|
||||
+gemCost(2)
|
||||
button.btn.btn-xs(ng-click='#{unlockPath("hair.base",colors)}')!= env.t('unlockSet',{cost:5}) + ' <span class="Pet_Currency_Gem1x inline-gems"/>'
|
||||
each num in colors
|
||||
button(class='hair_base_#{num}_{{user.preferences.hair.color}} customize-option', type='button', ng-class='{locked: !user.purchased.hair.base["#{num}"]}', ng-click='unlock("hair.base.#{num}")')
|
||||
|
||||
|
|
@ -93,9 +96,10 @@ mixin customizeProfile(mobile)
|
|||
button(class='hair_flower_#{num} customize-option', type='button', ng-click='set({"preferences.hair.flower":#{num}})')
|
||||
|
||||
li.customize-menu
|
||||
h5=env.t('bodyFacialHair')
|
||||
+gemCost(2)
|
||||
button.btn.btn-xs(ng-hide='user.purchased.hair.mustache["1"] && user.purchased.hair.mustache["2"] && user.purchased.hair.beard["1"] && user.purchased.hair.beard["2"] && user.purchased.hair.beard["3"]', ng-click='unlock("hair.mustache.1,hair.mustache.2,hair.beard.1,hair.beard.2,hair.beard.3")')!= env.t('unlockSet',{cost:5}) + ' <span class="Pet_Currency_Gem1x inline-gems"/>'
|
||||
menu(label=env.t('bodyFacialHair'))
|
||||
span(ng-hide='user.purchased.hair.mustache["1"] && user.purchased.hair.mustache["2"] && user.purchased.hair.beard["1"] && user.purchased.hair.beard["2"] && user.purchased.hair.beard["3"]')
|
||||
+gemCost(2)
|
||||
button.btn.btn-xs(ng-click='unlock("hair.mustache.1,hair.mustache.2,hair.beard.1,hair.beard.2,hair.beard.3")')!= env.t('unlockSet',{cost:5}) + ' <span class="Pet_Currency_Gem1x inline-gems"/>'
|
||||
|
||||
// Beard
|
||||
menu(label=env.t('beard'))
|
||||
|
|
@ -249,9 +253,9 @@ mixin backgrounds(mobile)
|
|||
- var k = bgsKeys[i], bgs = env.Content.backgrounds[k];
|
||||
li.customize-menu
|
||||
menu(label=env.t(k))
|
||||
+gemCost(7)
|
||||
//-button.btn.btn-xs(ng-hide="ownsSet('background',Content.backgrounds['#{k}'])",ng-click="unlock(setKeys('background',Content.backgrounds['#{k}']))")!= env.t('unlockSet',{cost:15}) + ' <span class="Pet_Currency_Gem1x inline-gems"/>'
|
||||
button.btn.btn-xs(ng-hide="ownsSet('background',#{JSON.stringify(bgs)})",ng-click="unlock(setKeys('background',#{JSON.stringify(bgs)}))")!= env.t('unlockSet',{cost:15}) + ' <span class="Pet_Currency_Gem1x inline-gems"/>'
|
||||
span(ng-hide="ownsSet('background',#{JSON.stringify(bgs)})")
|
||||
+gemCost(7)
|
||||
button.btn.btn-xs(ng-click="unlock(setKeys('background',#{JSON.stringify(bgs)}))")!= env.t('unlockSet',{cost:15}) + ' <span class="Pet_Currency_Gem1x inline-gems"/>'
|
||||
each bg,k in bgs
|
||||
button.customize-option(type='button', class='background_#{k}', ng-class="user.purchased.background.#{k} ? 'background-unlocked' : 'background-locked'", ng-click='unlock("background.#{k}")', popover-title=bg.text(), popover=bg.notes(),popover-trigger='mouseenter')
|
||||
i.glyphicon.glyphicon-lock(ng-if="!user.purchased.background.#{k}")
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ mixin chatMessages(inbox)
|
|||
|
|
||||
a(ng-click="flagChatMessage(group._id, message)", ng-if=':: user.contributor.admin || (!message.sent && user.flags.communityGuidelinesAccepted && message.uuid != user.id && message.uuid != "system")')
|
||||
span.glyphicon.glyphicon-flag(tooltip="{{message.flags[user._id] ? env.t('abuseAlreadyReported') : env.t('abuseFlag')}}" ng-class='message.flags[user._id] ? "text-danger" : ""')
|
||||
span.float-label
|
||||
span.float-label(ng-class='::contribText(message.contributor, message.backer).length > 30 ? "long-title" : ""')
|
||||
a.label.label-default.chat-message(ng-if=':: message.user', ng-class='::userLevelStyleFromLevel(message.contributor.level, message.backer.npc, style)', ng-click='clickMember(message.uuid, true)')
|
||||
span.glyphicon.glyphicon-arrow-right(ng-if='::message.sent')
|
||||
|
|
||||
|
|
|
|||
|
|
@ -19,5 +19,10 @@ form.col-md-12.form-horizontal(ng-submit='create(newGroup)')
|
|||
span.gem-cost= '4 ' + env.t('gems')
|
||||
p
|
||||
small=env.t('gemCost')
|
||||
.form-group
|
||||
.checkbox
|
||||
label
|
||||
input(type='checkbox', ng-model='newGroup.leaderOnly.challenges')
|
||||
| Only group leader can create challenges
|
||||
.form-group(ng-show='type=="party"')
|
||||
input.btn.btn-default.form-control(type='submit', value=env.t('create'))
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ a.pull-right.gem-wallet(ng-if='group.type!="party"', popover-trigger='mouseenter
|
|||
.form-group
|
||||
label=env.t('logoUrl')
|
||||
input.form-control(type='url', placeholder=env.t('logoUrl'), ng-model='group.logo')
|
||||
.form-group
|
||||
.checkbox
|
||||
label
|
||||
input(type='checkbox', ng-model='group.leaderOnly.challenges')
|
||||
| Only group leader can create challenges
|
||||
|
||||
h4=env.t('assignLeader')
|
||||
select#group-leader-selection(ng-model='group._newLeader', ng-options='member.profile.name for member in group.members')
|
||||
|
|
|
|||
|
|
@ -105,13 +105,13 @@ script(type='text/ng-template', id='partials/options.social.html')
|
|||
a(ui-sref='options.social.party')
|
||||
=env.t('party')
|
||||
li(ng-class="{ active: $state.includes('options.social.guilds') }")
|
||||
a(ui-sref='options.social.guilds')
|
||||
a(ui-sref='options.social.guilds.public')
|
||||
=env.t('guilds')
|
||||
li(ng-class="{ active: $state.includes('options.social.challenges') }")
|
||||
a(ui-sref='options.social.challenges')
|
||||
=env.t('challenges')
|
||||
li(ng-class="{ active: $state.includes('options.social.hall') }")
|
||||
a(ui-sref='options.social.hall')
|
||||
a(ui-sref='options.social.hall.heroes')
|
||||
=env.t('hall')
|
||||
|
||||
.tab-content
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
span(ng-hide='user.preferences.sleep')=env.t('innCheckIn')
|
||||
=env.t('danielText2')
|
||||
.alert.alert-info(ng-show='user.preferences.sleep')
|
||||
=env.t('innText')
|
||||
=env.t('innText',{name:"{{user.profile.name}}"})
|
||||
|
||||
// Resources
|
||||
.panel.panel-default
|
||||
|
|
|
|||
|
|
@ -34,11 +34,11 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}')
|
|||
li
|
||||
a(ui-sref='options.social.party')=env.t('party')
|
||||
li
|
||||
a(ui-sref='options.social.guilds')=env.t('guilds')
|
||||
a(ui-sref='options.social.guilds.public')=env.t('guilds')
|
||||
li
|
||||
a(ui-sref='options.social.challenges')=env.t('challenges')
|
||||
li
|
||||
a(ui-sref='options.social.hall')=env.t('hall')
|
||||
a(ui-sref='options.social.hall.heroes')=env.t('hall')
|
||||
ul.toolbar-submenu
|
||||
li
|
||||
a(ui-sref='options.inventory.drops')=env.t('market')
|
||||
|
|
@ -49,7 +49,9 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}')
|
|||
li
|
||||
a(ui-sref='options.inventory.equipment')=env.t('equipment')
|
||||
li
|
||||
a(ui-sref='options.inventory.timetravelers') Time Travelers
|
||||
a(ui-sref='options.inventory.timetravelers')=env.t('timeTravelers')
|
||||
li
|
||||
a(ui-sref='options.inventory.seasonalshop')=env.t('seasonalShop')
|
||||
ul.toolbar-submenu
|
||||
li
|
||||
a(target="_blank" ng-href='http://data.habitrpg.com?uuid={{user._id}}')=env.t('dataTool')
|
||||
|
|
@ -110,11 +112,11 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}')
|
|||
li
|
||||
a(ui-sref='options.social.party')=env.t('party')
|
||||
li
|
||||
a(ui-sref='options.social.guilds')=env.t('guilds')
|
||||
a(ui-sref='options.social.guilds.public')=env.t('guilds')
|
||||
li
|
||||
a(ui-sref='options.social.challenges')=env.t('challenges')
|
||||
li
|
||||
a(ui-sref='options.social.hall')=env.t('hall')
|
||||
a(ui-sref='options.social.hall.heroes')=env.t('hall')
|
||||
li.toolbar-button-dropdown
|
||||
a(ui-sref='options.inventory.drops')
|
||||
span=env.t('inventory')
|
||||
|
|
@ -131,7 +133,9 @@ nav.toolbar(ng-controller='AuthCtrl', ng-class='{active: isToolbarHidden}')
|
|||
li
|
||||
a(ui-sref='options.inventory.equipment')=env.t('equipment')
|
||||
li
|
||||
a(ui-sref='options.inventory.timetravelers') Time Travelers
|
||||
a(ui-sref='options.inventory.timetravelers')=env.t('timeTravelers')
|
||||
li
|
||||
a(ui-sref='options.inventory.seasonalshop')=env.t('seasonalShop')
|
||||
li.toolbar-button-dropdown
|
||||
a(target="_blank" ng-href='http://data.habitrpg.com?uuid={{user._id}}')
|
||||
span=env.t('data')
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ script(type='text/ng-template', id='modals/questCompleted.html')
|
|||
h4 "{{::Content.quests[user.party.quest.completed].text()}}"
|
||||
=env.t('completed')
|
||||
.modal-body
|
||||
div(ng-class='::Content.quests[user.party.quest.completed].completion() ? "pull-right-sm" : ""', class='col-centered quest_{{user.party.quest.completed}}')
|
||||
p(ng-bind-html='::Content.quests[user.party.quest.completed].completion()')
|
||||
quest-rewards(key='{{user.party.quest.completed}}', header=env.t('youReceived'))
|
||||
.modal-footer
|
||||
|
|
|
|||
|
|
@ -1,19 +1,40 @@
|
|||
h5 PENGUIN PET QUEST AND WINTER PLOT-LINE
|
||||
h5 ANDROID APP UPDATE, SEASONAL SHOP, AND WINTER PLOT-LINE CONTINUES
|
||||
tr
|
||||
td
|
||||
h5 Penguin Pet Quest
|
||||
.quest_penguin.pull-right
|
||||
p Habiticans wanted to go ice-skating, but instead, a giant penguin is freezing everything in sight! All we wanted was to go ice-skating... Can you get this penguin to chill out? If so, you'll be rewarded with some penguins of your own!
|
||||
p.small.muted by Melynnrose, Breadstrings, Rattify, Painter de Cluster, Daniel the Bard, and Leephon
|
||||
h5 Android App Update: December Art and Buying Gems!
|
||||
p The December backgrounds and penguin pet quest are now visible in the Android mobile app! Also, we’ve made it possible to buy gems directly from the app. Now you don’t have to switch to the website to stock up!
|
||||
p You can get the Android app <a href='https://play.google.com/store/apps/details?id=com.ocdevel.habitrpg&hl=en' target='_blank'>here</a>! We will announce when the iOS app is available as well.
|
||||
p.small.muted by negue
|
||||
tr
|
||||
td
|
||||
h5 Winter Plot-Line
|
||||
p Lemoness enters the Tavern with worrying news from the far north of Habitica. "Nobody's heard from the Stoïkalm Steppes for over a week," she says. "It's hard to imagine anything troubling the citizens there, since it's such a placid part of the continent... But just in case, maybe I should pay a visit." Sounds like a good plan to us!
|
||||
h5 Seasonal Shop Tab
|
||||
.seasonalshop_closed.pull-right
|
||||
p Looks like a new tab has appeared under Inventory - the <a href='https://habitrpg.com/#/options/inventory/seasonalshop'>Seasonal Shop</a>! It's still closed, but I've heard a rumor that it will open soon...
|
||||
p.small.muted by SabreCat and Lemoness
|
||||
tr
|
||||
td
|
||||
h5 Winter Plot-Line Continues
|
||||
p Lemoness bursts into the Tavern, shaking icicles off her hat. "The Stoïkalm Steppes are completely abandoned!" she says, gulping the cup of tea that Daniel the Barkeep offers her. "No people milling about, no mounts and pets playing in the snow - and when I tried to fly closer, my dragon spooked and refused to land!"
|
||||
p A cloaked figure in the corner steps into the fire light - SabreCat, a powerful adventurer from the north. "The Stoïkalm Steppes are the last home of many animals that have long since gone extinct elsewhere," he says. "The stoic Stoïkalmers would never flee their lands unless something was threatening their pets and mounts!"
|
||||
p He turns to Lemoness. "I can speak the language of the northern beasts. I'll try to contact the roaming sabertooth prides to see if they know what happened." As he lopes off into the distance, a cold wind begins to blow.
|
||||
p.muted Missed the first part of the Winter Plot-Line? Read it <a href='http://habitrpg.wikia.com/wiki/Whats_new'>here</a>.
|
||||
|
||||
hr
|
||||
a(href='/static/old-news', target='_blank') Read older news
|
||||
|
||||
mixin oldNews
|
||||
h5 12/9/2014 - Penguin Pet Quest and Winter Plot-Line
|
||||
tr
|
||||
td
|
||||
h5 Penguin Pet Quest
|
||||
.quest_penguin.pull-right
|
||||
p Habiticans wanted to go ice-skating, but instead, a giant penguin is freezing everything in sight! All we wanted was to go ice-skating... Can you get this penguin to chill out? If so, you'll be rewarded with some penguins of your own!
|
||||
p.small.muted by Melynnrose, Breadstrings, Rattify, Painter de Cluster, Daniel the Bard, and Leephon
|
||||
tr
|
||||
td
|
||||
h5 Winter Plot-Line
|
||||
p Lemoness enters the Tavern with worrying news from the far north of Habitica. "Nobody's heard from the Stoïkalm Steppes for over a week," she says. "It's hard to imagine anything troubling the citizens there, since it's such a placid part of the continent... But just in case, maybe I should pay a visit." Sounds like a good plan to us!
|
||||
|
||||
h5 12/3/2014 - Gifting Subscriptions And Gems, New Subscription Benefits, Mysterious Time Travelers, Steampunk Item Sets, And Block Subscriptions!
|
||||
table.table.table-striped
|
||||
tr
|
||||
|
|
|
|||
Loading…
Reference in a new issue