Compare commits

..

6 Commits

Author SHA1 Message Date
Phillip Thelen 934dd565f6 add april fools tests 2026-02-04 14:16:50 +01:00
Phillip Thelen 3057637b1e make april fools cycle through 2026-02-04 14:09:16 +01:00
Phillip Thelen 3156663ee5 fix lint 2026-02-02 14:41:26 +01:00
Phillip Thelen 4a9497b600 name key more generic 2026-02-02 12:34:46 +01:00
Phillip Thelen 35aab8d889 right date for april fools 2026-02-02 12:26:43 +01:00
Phillip Thelen 88c876c673 rework how april fools works 2026-02-02 12:26:19 +01:00
24 changed files with 152 additions and 121 deletions
+38
View File
@@ -0,0 +1,38 @@
import { getMatchingSwap, makeSubstitutionMap } from '../../website/common/script/content/constants/aprilFools';
describe('April Fools', () => {
describe('getMatchingSwap', () => {
it('returns Veggie for 2020', () => {
const swap = getMatchingSwap(new Date('2020-04-01'));
expect(swap).to.equal('Veggie');
});
it('returns Cryptid for 2025', () => {
const swap = getMatchingSwap(new Date('2025-04-01'));
expect(swap).to.equal('Cryptid');
});
it('Cycles through swaps correctly', () => {
const swap = getMatchingSwap(new Date('2026-04-01'));
expect(swap).to.equal('Veggie');
});
});
describe('makeSubstitutionMap', () => {
it('returns correct substitution for Veggie', () => {
const substitutions = makeSubstitutionMap('Veggie');
expect(substitutions.pets['Pet-Wolf-']).to.equal('Pet-Wolf-Veggie');
expect(substitutions.pets['Pet-TigerCub-']).to.equal('Pet-TigerCub-Veggie');
expect(substitutions.pets['Pet-Yarn-']).to.equal('Pet-Dragon-Veggie');
expect(substitutions.pets.default).to.equal('Pet-Dragon-Veggie');
expect(substitutions.pets.noPet).to.equal('Pet-TigerCub-Veggie');
});
it('returns correct substitution for Cryptid', () => {
const substitutions = makeSubstitutionMap('Cryptid');
expect(substitutions.pets['Pet-Fox-']).to.equal('Pet-Fox-Cryptid');
expect(substitutions.pets['Pet-FlyingPig-']).to.equal('Pet-FlyingPig-Cryptid');
expect(substitutions.pets['Pet-Yarn-']).to.equal('Pet-Dragon-Cryptid');
expect(substitutions.pets.default).to.equal('Pet-Dragon-Cryptid');
expect(substitutions.pets.noPet).to.equal('Pet-TigerCub-Cryptid');
});
});
});
+5 -4
View File
@@ -321,10 +321,11 @@ export default {
return null;
},
petClass () {
const foolEvent = this.currentEventList?.find(event => event.aprilFools && moment()
.isBetween(event.start, event.end));
if (foolEvent) {
return this.foolPet(this.member.items.currentPet, foolEvent.aprilFools);
const substitutionEvent = this.currentEventList?.find(event => event.spriteSubstitutions
&& moment().isBetween(event.start, event.end));
if (substitutionEvent && substitutionEvent.spriteSubstitutions.pets) {
return this.foolPet(`Pet-${this.member.items.currentPet}`,
substitutionEvent.spriteSubstitutions.pets);
}
if (this.member?.items.currentPet) return `Pet-${this.member.items.currentPet}`;
return '';
@@ -182,12 +182,10 @@ export default {
return 'GreyedOut';
},
imageName () {
const foolEvent = this.currentEventList?.find(event => moment()
.isBetween(event.start, event.end) && event.aprilFools);
if (this.isOwned() && foolEvent) {
if (this.isSpecial()) return `stable_${this.foolPet(this.item.key, foolEvent.aprilFools)}`;
const petString = `${this.item.eggKey}-${this.item.key}`;
return `stable_${this.foolPet(petString, foolEvent.aprilFools)}`;
const substitutionEvent = this.currentEventList?.find(event => moment()
.isBetween(event.start, event.end) && event.spriteSubstitutions);
if (this.isOwned() && substitutionEvent && substitutionEvent.spriteSubstitutions.pets) {
return `stable_${this.foolPet(`Pet-${this.item.key}`, substitutionEvent.spriteSubstitutions.pets)}`;
}
if (this.isOwned() || (this.mountOwned() && this.isHatchable())) {
+8 -50
View File
@@ -1,56 +1,14 @@
import includes from 'lodash/includes';
export default {
methods: {
foolPet (pet, prank) {
const SPECIAL_PETS = [
'Bear-Veteran',
'BearCub-Polar',
'Cactus-Veteran',
'Dragon-Hydra',
'Dragon-Veteran',
'Fox-Veteran',
'Gryphatrice-Jubilant',
'Gryphon-Gryphatrice',
'Gryphon-RoyalPurple',
'Hippogriff-Hopeful',
'Jackalope-RoyalPurple',
'JackOLantern-Base',
'JackOLantern-Ghost',
'JackOLantern-Glow',
'JackOLantern-RoyalPurple',
'Lion-Veteran',
'MagicalBee-Base',
'Mammoth-Base',
'MantisShrimp-Base',
'Orca-Base',
'Phoenix-Base',
'Tiger-Veteran',
'Turkey-Base',
'Turkey-Gilded',
'Wolf-Cerberus',
'Wolf-Veteran',
];
const BASE_PETS = [
'BearCub',
'Cactus',
'Dragon',
'FlyingPig',
'Fox',
'LionCub',
'PandaCub',
'TigerCub',
'Wolf',
];
if (!pet) return `Pet-TigerCub-${prank}`;
if (SPECIAL_PETS.indexOf(pet) !== -1) {
return `Pet-Dragon-${prank}`;
foolPet (pet, substitutions) {
console.log(pet);
if (!pet) return substitutions.noPet;
for (const key in substitutions) {
if (pet.startsWith(key)) {
return substitutions[key];
}
}
const species = pet.slice(0, pet.indexOf('-'));
if (includes(BASE_PETS, species)) {
return `Pet-${species}-${prank}`;
}
return `Pet-BearCub-${prank}`;
return substitutions.default;
},
},
};
@@ -0,0 +1,41 @@
import eggs from '../eggs';
const SWAPS = [
'Veggie',
'Dessert',
'VirtualPet',
'TeaShop',
'Fungi',
'Cryptid',
];
export function getMatchingSwap (date = new Date()) {
const year = date.getFullYear();
const diff = year - 2020;
return SWAPS[diff % SWAPS.length];
}
export function makeSubstitutionMap (swappedPotion) {
const substitutions = {
pets: {
'Pet-Wolf-': `Pet-Wolf-${swappedPotion}`,
'Pet-TigerCub-': `Pet-TigerCub-${swappedPotion}`,
'Pet-PandaCub-': `Pet-PandaCub-${swappedPotion}`,
'Pet-LionCub-': `Pet-LionCub-${swappedPotion}`,
'Pet-Fox-': `Pet-Fox-${swappedPotion}`,
'Pet-FlyingPig-': `Pet-FlyingPig-${swappedPotion}`,
'Pet-Dragon-': `Pet-Dragon-${swappedPotion}`,
'Pet-Cactus-': `Pet-Cactus-${swappedPotion}`,
'Pet-BearCub-': `Pet-BearCub-${swappedPotion}`,
default: `Pet-Dragon-${swappedPotion}`,
noPet: `Pet-TigerCub-${swappedPotion}`,
},
};
for (const egg of Object.keys(eggs.drops)) {
substitutions.pets[`Pet-${egg}-`] = `Pet-${egg}-${swappedPotion}`;
}
for (const egg of Object.keys(eggs.quests)) {
substitutions.pets[`Pet-${egg}-`] = `Pet-Dragon-${swappedPotion}`;
}
return substitutions;
}
@@ -1,5 +1,6 @@
/* eslint-disable key-spacing */
import moment from 'moment';
import { getMatchingSwap, makeSubstitutionMap } from './aprilFools';
// gem block: number of gems
const gemsPromo = {
@@ -53,7 +54,7 @@ export const REPEATING_EVENTS = {
aprilFools: {
start: new Date('1970-04-01T04:00-04:00'),
end: new Date('1970-04-02T03:59-04:00'),
aprilFools: 'Cryptid',
spriteSubstitutions: makeSubstitutionMap(getMatchingSwap()),
},
aprilFoolsResale: {
start: new Date('1970-04-03T04:00-04:00'),
+2 -5
View File
@@ -1353,8 +1353,6 @@ api.getLookingForParty = {
const seekers = await User
.find({
'auth.blocked': { $ne: true },
'flags.chatRevoked': { $ne: true },
'party.seeking': { $exists: true },
'invitations.party.id': { $exists: false },
'auth.timestamps.loggedin': {
@@ -1362,13 +1360,12 @@ api.getLookingForParty = {
},
})
// eslint-disable-next-line no-multi-str
.select('_id auth.local.username auth.timestamps backer contributor.level \
flags.classSelected inbox.blocks invitations.party items.gear.costume \
.select('_id auth.blocked auth.local.username auth.timestamps backer contributor.level \
flags.chatRevoked flags.classSelected inbox.blocks invitations.party items.gear.costume \
items.gear.equipped loginIncentives party._id preferences.background preferences.chair \
preferences.costume preferences.hair preferences.shirt preferences.size preferences.skin \
preferences.language profile.name stats.buffs stats.class stats.lvl')
.sort('-auth.timestamps.loggedin')
.lean()
.exec();
const filteredSeekers = seekers.filter(seeker => {
+1 -1
View File
@@ -24,7 +24,7 @@ const api = {};
api.getInboxMessages = {
method: 'GET',
url: '/inbox/messages',
middlewares: [authWithHeaders({ leanUser: true, userFieldsToInclude: ['profile', 'contributor', 'backer', 'inbox'] })],
middlewares: [authWithHeaders({ userFieldsToInclude: ['profile', 'contributor', 'backer', 'inbox'] })],
async handler (req, res) {
const { user } = res.locals;
const { page } = req.query;
+6 -6
View File
@@ -40,7 +40,7 @@ const api = {};
api.createTag = {
method: 'POST',
url: '/tags',
middlewares: [authWithHeaders({ userFieldsToInclude: ['tags'] })],
middlewares: [authWithHeaders()],
async handler (req, res) {
const { user } = res.locals;
@@ -69,7 +69,7 @@ api.createTag = {
api.getTags = {
method: 'GET',
url: '/tags',
middlewares: [authWithHeaders({ leanUser: true, userFieldsToInclude: ['tags'] })],
middlewares: [authWithHeaders()],
async handler (req, res) {
const { user } = res.locals;
res.respond(200, user.tags);
@@ -95,7 +95,7 @@ api.getTags = {
api.getTag = {
method: 'GET',
url: '/tags/:tagId',
middlewares: [authWithHeaders({ leanUser: true, userFieldsToInclude: ['tags'] })],
middlewares: [authWithHeaders()],
async handler (req, res) {
const { user } = res.locals;
@@ -133,7 +133,7 @@ api.getTag = {
api.updateTag = {
method: 'PUT',
url: '/tags/:tagId',
middlewares: [authWithHeaders({ userFieldsToInclude: ['tags'] })],
middlewares: [authWithHeaders()],
async handler (req, res) {
const { user } = res.locals;
@@ -175,7 +175,7 @@ api.updateTag = {
api.reorderTags = {
method: 'POST',
url: '/reorder-tags',
middlewares: [authWithHeaders({ userFieldsToInclude: ['tags'] })],
middlewares: [authWithHeaders()],
async handler (req, res) {
const { user } = res.locals;
@@ -215,7 +215,7 @@ api.reorderTags = {
api.deleteTag = {
method: 'DELETE',
url: '/tags/:tagId',
middlewares: [authWithHeaders({ userFieldsToInclude: ['tags'] })],
middlewares: [authWithHeaders()],
async handler (req, res) {
const { user } = res.locals;
+2 -2
View File
@@ -388,7 +388,7 @@ api.getUserTasks = {
method: 'GET',
url: '/tasks/user',
middlewares: [authWithHeaders({
leanUser: true,
// Some fields (including _id, preferences) are always loaded (see middlewares/auth)
userFieldsToInclude: ['tasksOrder'],
})],
async handler (req, res) {
@@ -953,7 +953,7 @@ api.addChecklistItem = {
api.scoreCheckListItem = {
method: 'POST',
url: '/tasks/:taskId/checklist/:itemId/score',
middlewares: [authWithHeaders({ leanUser: true, userFieldsToInclude: ['_id'] })],
middlewares: [authWithHeaders()],
async handler (req, res) {
const { user } = res.locals;
+1 -1
View File
@@ -406,7 +406,7 @@ api.getUserAnonymized = {
{ type: { $in: ['habit', 'daily', 'reward'] } },
],
};
const tasks = await Tasks.Task.find(query).lean().exec();
const tasks = await Tasks.Task.find(query).exec();
forEach(tasks, task => {
task.text = 'task text';
@@ -22,7 +22,6 @@ api.purchaseHistory = {
let transactions = await Transaction
.find({ userId: req.params.memberId })
.sort({ createdAt: -1 })
.lean()
.exec();
if (!res.locals.user.hasPermission('userSupport')) {
+1 -3
View File
@@ -313,9 +313,7 @@ api.purchaseHistory = {
url: '/user/purchase-history',
async handler (req, res) {
const { user } = res.locals;
const transactions = await Transaction.find({ userId: user._id })
.sort({ createdAt: -1 })
.lean();
const transactions = await Transaction.find({ userId: user._id }).sort({ createdAt: -1 });
res.respond(200, transactions);
},
};
@@ -53,7 +53,7 @@ api.exportUserHistory = {
const tasks = await Tasks.Task.find({
userId: user._id,
type: { $in: ['habit', 'daily'] },
}).lean().exec();
}).exec();
const output = [
['Task Name', 'Task ID', 'Task Type', 'Date', 'Value'],
@@ -92,7 +92,7 @@ async function _getUserDataForExport (user) {
const [tasks, messages] = await Promise.all([
Tasks.Task.find({
userId: user._id,
}).lean().exec(),
}).exec(),
inboxLib.getUserInbox(user, { asArray: false }),
]);
@@ -100,6 +100,7 @@ async function _getUserDataForExport (user) {
userData.inbox.messages = messages;
_(tasks)
.map(task => task.toJSON())
.groupBy(task => task.type)
.forEach((tasksPerType, taskType) => {
userData.tasks[`${taskType}s`] = tasksPerType;
-1
View File
@@ -22,7 +22,6 @@ export async function sendChatPushNotifications (user, group, message, mentions,
'party._id': group._id,
_id: { $ne: user._id },
})
.lean()
.select('preferences.pushNotifications preferences.language profile.name pushDevices auth.local.username')
.exec();
+2 -2
View File
@@ -25,13 +25,13 @@ export async function getGroupChat (group, options = {}) {
.sort('-timestamp');
if (before) {
const beforeMessage = await Chat.findOne({ _id: before }, { timestamp: 1 }).lean().exec();
const beforeMessage = await Chat.findOne({ _id: before }).exec();
if (beforeMessage) {
query = query.where('timestamp').lt(beforeMessage.timestamp);
}
}
const groupChat = await query.limit(effectiveLimit).lean().exec();
const groupChat = await query.limit(effectiveLimit).exec();
// @TODO: Concat old chat to keep continuity of chat stored on group object
const currentGroupChat = group.chat || [];
@@ -22,7 +22,7 @@ async function usersMapByConversations (users) {
stats: 1,
flags: 1,
inbox: 1,
}).lean().exec();
}).exec();
for (const usr of loadedUsers) {
const loadedUserConversation = {
+1 -4
View File
@@ -169,10 +169,7 @@ api.subscribe = async function subscribe (user, receipt, headers, nextPaymentPro
{ 'purchased.plan.customerId': purchase.originalTransactionId },
{ 'purchased.plan.customerId': purchase.transactionId },
],
}, {
_id: 1,
'purchased.plan': 1,
}).lean().exec();
}).exec();
if (existingUsers.length > 0) {
if (purchase.originalTransactionId === purchase.transactionId) {
throw new NotAuthorized(this.constants.RESPONSE_ALREADY_USED);
+3 -12
View File
@@ -178,7 +178,7 @@ async function getTasks (req, res, options = {}) {
],
},
{ _id: 1 },
).lean().exec();
).exec();
}
if (upgradedGroups.length > 0) {
for (const upgradedGroup of upgradedGroups) {
@@ -270,6 +270,7 @@ async function getTasks (req, res, options = {}) {
remove(taskOrder, taskId => tasks.findIndex(task => task._id === taskId) === -1);
if (preLength !== taskOrder.length) {
owner.tasksOrder[key] = taskOrder;
owner.markModified('tasksOrder');
ownerDirty = true;
}
});
@@ -302,17 +303,7 @@ async function getTasks (req, res, options = {}) {
}
});
if (ownerDirty) {
let model;
if (challenge) {
model = Challenge;
} else if (group) {
model = Group;
} else {
model = User;
}
await model.updateOne({ _id: owner._id }, { tasksOrder: owner.tasksOrder }).exec();
}
if (ownerDirty) await owner.save();
// Remove empty values from the array and add any unordered task
orderedTasks = compact(orderedTasks).concat(unorderedTasks);
+1 -1
View File
@@ -82,7 +82,7 @@ export function setNextDue (task, user, dueDateOption) {
now = dateTaskIsDue;
}
const optionsForShouldDo = user.preferences;
const optionsForShouldDo = user.preferences.toObject();
optionsForShouldDo.now = now;
task.isDue = shared.shouldDo(dateTaskIsDue, task, optionsForShouldDo);
+1 -1
View File
@@ -186,7 +186,7 @@ export async function update (req, res, { isV3 = false }) {
],
}, {
_id: 1,
}).lean().exec();
}).exec();
matchingGroupsArray = _.map(matchingGroups, groupRecord => groupRecord._id);
}
-1
View File
@@ -43,7 +43,6 @@ schema.statics.getNews = async function getNews (isAdmin, options = { page: 0 })
.sort({ publishDate: -1 })
.limit(POSTS_PER_PAGE)
.skip(POSTS_PER_PAGE * Number(page))
.lean()
.exec();
};
+17
View File
@@ -1,5 +1,6 @@
import mongoose from 'mongoose';
import logger from '../../libs/logger';
import schema from './schema'; // eslint-disable-line import/no-cycle
import './hooks'; // eslint-disable-line import/no-cycle
@@ -18,3 +19,19 @@ export const nameFields = 'profile.name auth.local.username flags.verifiedUserna
export { schema };
export const model = mongoose.model('User', schema);
// Initially export an empty object so external requires will get
// the right object by reference when it's defined later
// Otherwise it would remain undefined if requested before the query executes
export const mods = [];
mongoose.model('User')
.find({ 'contributor.moderator': true })
.sort('-contributor.level -backer.npc profile.name')
.select('profile contributor backer')
.exec()
.then(foundMods => {
// Using push to maintain the reference to mods
mods.push(...foundMods);
})
.catch(err => logger.error(err));
+12 -16
View File
@@ -367,14 +367,14 @@ schema.methods.getUtcOffset = function getUtcOffset () {
return common.fns.getUtcOffset(this);
};
schema.statics.daysUserHasMissed = function daysUserHasMissed (user, now, req = {}) {
schema.methods.daysUserHasMissed = function daysUserHasMissed (now, req = {}) {
// If the user's timezone has changed (due to travel or daylight savings),
// cron can be triggered twice in one day, so we check for that and use
// both timezones to work out if cron should run.
// CDS = Custom Day Start time.
let timezoneUtcOffsetFromUserPrefs = common.fns.getUtcOffset(user);
const timezoneUtcOffsetAtLastCron = Number.isFinite(user.preferences.timezoneOffsetAtLastCron)
? -user.preferences.timezoneOffsetAtLastCron
let timezoneUtcOffsetFromUserPrefs = this.getUtcOffset();
const timezoneUtcOffsetAtLastCron = Number.isFinite(this.preferences.timezoneOffsetAtLastCron)
? -this.preferences.timezoneOffsetAtLastCron
: timezoneUtcOffsetFromUserPrefs;
let timezoneUtcOffsetFromBrowser = typeof req.header === 'function' && -Number(req.header('x-user-timezoneoffset'));
@@ -386,16 +386,16 @@ schema.statics.daysUserHasMissed = function daysUserHasMissed (user, now, req =
if (timezoneUtcOffsetFromBrowser !== timezoneUtcOffsetFromUserPrefs) {
// The user's browser has just told Habitica that the user's timezone has
// changed so store and use the new zone.
user.preferences.timezoneOffset = -timezoneUtcOffsetFromBrowser;
this.preferences.timezoneOffset = -timezoneUtcOffsetFromBrowser;
timezoneUtcOffsetFromUserPrefs = timezoneUtcOffsetFromBrowser;
}
let lastCronTime = user.lastCron;
if (user.auth.timestamps.loggedIn < lastCronTime) {
lastCronTime = user.auth.timestamps.loggedIn;
let lastCronTime = this.lastCron;
if (this.auth.timestamps.loggedIn < lastCronTime) {
lastCronTime = this.auth.timestamps.loggedIn;
}
// How many days have we missed using the user's current timezone:
let daysMissed = daysSince(lastCronTime, defaults({ now }, user.preferences));
let daysMissed = daysSince(lastCronTime, defaults({ now }, this.preferences));
if (timezoneUtcOffsetAtLastCron !== timezoneUtcOffsetFromUserPrefs) {
// Give the user extra time based on the difference in timezones
@@ -410,7 +410,7 @@ schema.statics.daysUserHasMissed = function daysUserHasMissed (user, now, req =
const daysMissedOldZone = daysSince(lastCronTime, defaults({
now,
timezoneUtcOffsetOverride: timezoneUtcOffsetAtLastCron,
}, user.preferences));
}, this.preferences));
if (timezoneUtcOffsetAtLastCron > timezoneUtcOffsetFromUserPrefs) {
// The timezone change was in the unsafe direction.
@@ -447,12 +447,12 @@ schema.statics.daysUserHasMissed = function daysUserHasMissed (user, now, req =
const timezoneOffsetDiff = timezoneUtcOffsetFromUserPrefs - timezoneUtcOffsetAtLastCron;
// e.g., for dangerous zone change: -300 - -240 = -60 or 600 - 660= -60
user.lastCron = moment(lastCronTime).subtract(timezoneOffsetDiff, 'minutes');
this.lastCron = moment(lastCronTime).subtract(timezoneOffsetDiff, 'minutes');
// NB: We don't change this.auth.timestamps.loggedin so that will still record
// the time that the previous cron actually ran.
// From now on we can ignore the old timezone:
// This is still timezoneOffset for backwards compatibility reasons.
user.preferences.timezoneOffsetAtLastCron = -timezoneUtcOffsetAtLastCron;
this.preferences.timezoneOffsetAtLastCron = -timezoneUtcOffsetAtLastCron;
} else {
// Both old and new timezones indicate that cron should
// NOT run.
@@ -474,10 +474,6 @@ schema.statics.daysUserHasMissed = function daysUserHasMissed (user, now, req =
return { daysMissed, timezoneUtcOffsetFromUserPrefs };
};
schema.methods.daysUserHasMissed = function daysUserHasMissed (now, req = {}) {
return schema.statics.daysUserHasMissed(this, now, req);
};
async function getUserGroupData (user) {
const userGroups = user.getGroups();